<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Mursalin Kabir</title><description>Technology, money and life in Japan.</description><link>https://smkabir.com/</link><language>en</language><item><title>How to connect an AI agent to TraderBuddy over MCP</title><link>https://smkabir.com/posts/traderbuddy-mcp/</link><guid isPermaLink="true">https://smkabir.com/posts/traderbuddy-mcp/</guid><description>Connect Claude Code, Cursor or any MCP client to TraderBuddy in three steps: a personal access token, one command, a check. What the agent can do, and what it never can.</description><pubDate>Sat, 17 Oct 2026 16:10:10 GMT</pubDate><content:encoded>&lt;p&gt;TraderBuddy runs an MCP server, so Claude Code, Cursor and other AI agents can look up a stock, read your alerts and portfolios, and manage alerts, your watchlist, chart drawings, saved scanners, the daily digest and your portfolio transactions without opening the browser. It takes about two minutes to connect. It tracks and alerts; it never trades, and nothing an agent does can place an order.&lt;/p&gt;
&lt;h2 id=&quot;what-you-can-ask&quot;&gt;What you can ask&lt;/h2&gt;
&lt;p&gt;Once it is connected, you talk to your agent in plain words and it calls TraderBuddy&amp;#39;s tools for you:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;em&gt;&amp;quot;Give me the full picture on NVDA.&amp;quot;&lt;/em&gt; Price and 1d to 1y change, the 52-week range, distance from the 50- and 200-day averages, RSI, valuation, analyst ratings, and whether you already watch, hold or have an alert on it.&lt;/li&gt;&lt;li&gt;&lt;em&gt;&amp;quot;Alert me if SPY closes below its 200-day average.&amp;quot;&lt;/em&gt;&lt;/li&gt;&lt;li&gt;&lt;em&gt;&amp;quot;Which of my alerts have triggered this month, and why?&amp;quot;&lt;/em&gt;&lt;/li&gt;&lt;li&gt;&lt;em&gt;&amp;quot;Scan the S&amp;amp;P 500 for stocks with RSI under 30 that are still above their 200-day average, and save it as &amp;#39;Dip buys&amp;#39;.&amp;quot;&lt;/em&gt;&lt;/li&gt;&lt;li&gt;&lt;em&gt;&amp;quot;Draw a support line at 187.50 on AAPL and alert me on it.&amp;quot;&lt;/em&gt;&lt;/li&gt;&lt;li&gt;&lt;em&gt;&amp;quot;What are the macro readings that say risk-off right now?&amp;quot;&lt;/em&gt;&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;how-it-works&quot;&gt;How it works&lt;/h2&gt;
&lt;p&gt;The endpoint is &lt;code&gt;https://trader.smkabir.com/api/mcp/&lt;/code&gt;, and it signs you in with a &lt;strong&gt;personal access token&lt;/strong&gt; that you create yourself. There is no OAuth sign-in for agents: Zitadel, the identity provider, does not support the dynamic client registration agents need, so a token is how an agent proves it is acting for you.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;(Diagram: view it on the website.)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The MCP server is not a separate service. It lives inside the API, and every tool calls the same REST routes the browser uses, carrying your token. So an agent gets exactly what you get in the browser: the same validation, the same approval check, the same per-user privacy and the same rate limits. Nothing is reimplemented, so nothing can drift or be bypassed.&lt;/p&gt;
&lt;h2 id=&quot;step-1-create-a-token&quot;&gt;Step 1: create a token&lt;/h2&gt;
&lt;p&gt;In TraderBuddy, open &lt;strong&gt;Settings → Agent access → New token&lt;/strong&gt;. Give it a name you will recognise (&amp;quot;Claude Code on laptop&amp;quot;), choose what it may do, and pick an expiry: 30 days, 90 days or a year. There is no &amp;quot;never&amp;quot;.&lt;/p&gt;
&lt;table&gt;&lt;tr&gt;&lt;th&gt;Scope&lt;/th&gt;&lt;th&gt;The agent can&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Read only&lt;/td&gt;&lt;td&gt;Look at everything listed below. Change nothing.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Read and write&lt;/td&gt;&lt;td&gt;Also create, change, renew and delete alerts; manage the watchlist; draw and delete chart lines; save, change and delete scanners; turn the digest and opening movers on or off; create a portfolio and add or correct its transactions.&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;p&gt;The token starts with &lt;code&gt;tb_pat_&lt;/code&gt; and is &lt;strong&gt;shown once&lt;/strong&gt;. TraderBuddy keeps only a hash of it, so if you lose it, revoke it and make another. Treat it like a password: anyone holding it can act as you within its scope. Start with read-only, and make a read-and-write token only when you want the agent to change things.&lt;/p&gt;
&lt;h2 id=&quot;step-2-give-it-to-your-agent&quot;&gt;Step 2: give it to your agent&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Claude Code&lt;/strong&gt; takes one command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;claude mcp add --transport http traderbuddy https://trader.smkabir.com/api/mcp/ \
  --header &amp;quot;Authorization: Bearer tb_pat_…&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Cursor&lt;/strong&gt; reads &lt;code&gt;~/.cursor/mcp.json&lt;/code&gt; (or &lt;code&gt;.cursor/mcp.json&lt;/code&gt; in a project):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &amp;quot;mcpServers&amp;quot;: {
    &amp;quot;traderbuddy&amp;quot;: {
      &amp;quot;url&amp;quot;: &amp;quot;https://trader.smkabir.com/api/mcp/&amp;quot;,
      &amp;quot;headers&amp;quot;: { &amp;quot;Authorization&amp;quot;: &amp;quot;Bearer tb_pat_…&amp;quot; }
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Any other client&lt;/strong&gt; that supports a remote (HTTP) MCP server with a custom header works the same way: the URL above, and the header &lt;code&gt;Authorization: Bearer tb_pat_…&lt;/code&gt;. Where that goes differs between clients and versions, so follow your client&amp;#39;s own documentation. The URL works with or without the trailing slash.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;A project&amp;#39;s &lt;code&gt;.cursor/mcp.json&lt;/code&gt; is easy to commit by accident. Put the token in your user-level config, or make sure the project file is ignored.&lt;/p&gt;&lt;/blockquote&gt;
&lt;h2 id=&quot;step-3-check-it-is-connected&quot;&gt;Step 3: check it is connected&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;$ claude mcp list
traderbuddy: https://trader.smkabir.com/api/mcp/ (HTTP) - ✔ Connected&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then ask the agent &lt;em&gt;&amp;quot;Who am I in TraderBuddy?&amp;quot;&lt;/em&gt;. It calls &lt;code&gt;whoami&lt;/code&gt; and answers with your account and whether it is approved. A pending account&amp;#39;s token is refused until an admin approves it.&lt;/p&gt;
&lt;h2 id=&quot;the-tools&quot;&gt;The tools&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Read tools, any token:&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;&lt;tr&gt;&lt;th&gt;Tool&lt;/th&gt;&lt;th&gt;Does&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;get_stock_details&lt;/code&gt;&lt;/td&gt;&lt;td&gt;The one to start with: price, changes, 52-week range, trend, valuation, analysts, and what you already have on that symbol.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;search_symbols&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Find a ticker by name. Says whether it is an ETF or fund.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;get_bars&lt;/code&gt;, &lt;code&gt;get_indicator&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Candles and server-computed indicators: the same numbers the chart shows.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;get_market_clock&lt;/code&gt;, &lt;code&gt;get_news&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Whether the market is open; recent headlines.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;list_macro&lt;/code&gt;, &lt;code&gt;get_macro_series&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Treasury yields, Fed funds, CPI, unemployment, VIX, CAPE, each with whether a rise is good or bad.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;list_watchlist&lt;/code&gt;, &lt;code&gt;list_alerts&lt;/code&gt;, &lt;code&gt;get_alert_events&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Your watchlist, your alerts, and each time an alert fired and why.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;list_portfolios&lt;/code&gt;, &lt;code&gt;get_portfolio&lt;/code&gt;, &lt;code&gt;get_portfolio_transactions&lt;/code&gt;, &lt;code&gt;get_portfolio_history&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Value, P&amp;amp;L, positions, allocation and the ledger.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;get_financials&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Financial health from the latest 10-K or 10-Q: a 0–100 score, five pillars, the Piotroski and Altman checks, and optionally five years of statements.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;get_scanner_fields&lt;/code&gt;, &lt;code&gt;list_scanners&lt;/code&gt;, &lt;code&gt;run_scanner&lt;/code&gt;, &lt;code&gt;scan&lt;/code&gt;&lt;/td&gt;&lt;td&gt;List what a scan can filter on, run a saved or built-in scanner, or try an unsaved one. Results are as of the last close.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;list_drawings&lt;/code&gt;, &lt;code&gt;get_digest_settings&lt;/code&gt;, &lt;code&gt;list_notification_channels&lt;/code&gt;, &lt;code&gt;whoami&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Chart drawings, digest settings, where alerts are delivered (never the credentials), and your account.&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Write tools, read-and-write token:&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;&lt;tr&gt;&lt;th&gt;Tool&lt;/th&gt;&lt;th&gt;Does&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;create_price_alert&lt;/code&gt;, &lt;code&gt;create_condition_alert&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Alert on a price level, or on a moving-average cross, golden or death cross, daily % move, new high or low, volume spike or RSI level.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;update_alert&lt;/code&gt;, &lt;code&gt;renew_alerts&lt;/code&gt;, &lt;code&gt;delete_alerts&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Change an alert, extend it by 90 days, or remove it.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;add_to_watchlist&lt;/code&gt;, &lt;code&gt;remove_from_watchlist&lt;/code&gt;, &lt;code&gt;reorder_watchlist&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Manage the watchlist.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;draw_horizontal_line&lt;/code&gt;, &lt;code&gt;draw_trendline&lt;/code&gt;, &lt;code&gt;delete_drawing&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Draw on a chart, optionally with an alert on the line.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;create_scanner&lt;/code&gt;, &lt;code&gt;update_scanner&lt;/code&gt;, &lt;code&gt;delete_scanner&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Save a scan; &lt;code&gt;notify&lt;/code&gt; sends its matches to your channels after each close.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;create_portfolio&lt;/code&gt;, &lt;code&gt;add_portfolio_transactions&lt;/code&gt;, &lt;code&gt;update_portfolio_transaction&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Record holdings: up to 200 transactions at once, all stored or none. The whole ledger is replayed, so an impossible sell is refused and the reply names the row.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;set_digest_settings&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Turn the daily digest and opening movers on or off.&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;p&gt;Tools that delete are marked as destructive, so a client can ask you before running them.&lt;/p&gt;
&lt;h2 id=&quot;what-an-agent-can-never-do&quot;&gt;What an agent can never do&lt;/h2&gt;
&lt;p&gt;These are not settings. They are walls in the API, enforced no matter which client or token is used:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Trade.&lt;/strong&gt; There is no order entry anywhere in TraderBuddy.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Touch notification credentials.&lt;/strong&gt; It can see that a Slack, Telegram or webhook channel exists and whether it works, but cannot add, change, remove or test one. Otherwise bot tokens would end up in the agent&amp;#39;s conversation history.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Use the admin area&lt;/strong&gt;, even if your account is an admin. A token carries no roles.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Create, list or revoke tokens.&lt;/strong&gt; Only a signed-in browser can, so a leaked read-only token cannot upgrade itself.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Delete from a portfolio.&lt;/strong&gt; It can add and correct transactions, but not delete a transaction or a portfolio, rename one, or import a file. Anything it adds wrongly, you remove in the browser.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;Anything not on the allowlist is refused by default, and a test fails the build if a new route is added without a decision about whether tokens may reach it.&lt;/p&gt;
&lt;h2 id=&quot;things-worth-knowing&quot;&gt;Things worth knowing&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Alerts are not live.&lt;/strong&gt; They are checked once per trading day, after the US close. &amp;quot;Alert me when it crosses 200&amp;quot; means &amp;quot;tell me after the close on the day it did&amp;quot;. The agent is told this and should not promise instant notification.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Money is a string.&lt;/strong&gt; Prices and quantities come back as exact decimals in strings (&lt;code&gt;&amp;quot;187.500000&amp;quot;&lt;/code&gt;), never floats.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;ETFs yes; mutual funds and index levels no.&lt;/strong&gt; SPY, QQQ and VOO work. VFIAX and the S&amp;amp;P 500 index itself are not in the data, so alert on the tracking ETF.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Rate limit.&lt;/strong&gt; 120 requests a minute per token. &lt;code&gt;get_stock_details&lt;/code&gt; makes about a dozen requests by itself.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;No duplicate detection for transactions.&lt;/strong&gt; Ask the agent to read the portfolio&amp;#39;s transactions before it adds a statement, and add a given statement once.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;when-something-goes-wrong&quot;&gt;When something goes wrong&lt;/h2&gt;
&lt;table&gt;&lt;tr&gt;&lt;th&gt;You see&lt;/th&gt;&lt;th&gt;Cause&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;401 &amp;quot;Missing or malformed access token&amp;quot;&lt;/td&gt;&lt;td&gt;No &lt;code&gt;Authorization: Bearer tb_pat_…&lt;/code&gt; header reached the server.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;401 &amp;quot;invalid, expired or revoked&amp;quot;&lt;/td&gt;&lt;td&gt;Exactly that. The three look the same on purpose. Make a new token.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;403 &amp;quot;account has not been approved&amp;quot;&lt;/td&gt;&lt;td&gt;An admin has not approved your account yet.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&amp;quot;This access token is read-only&amp;quot;&lt;/td&gt;&lt;td&gt;The call needs a read-and-write token.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;429 &amp;quot;Rate limited&amp;quot;&lt;/td&gt;&lt;td&gt;Over 120 requests a minute. The message says how long to wait.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Tools return nothing for a symbol&lt;/td&gt;&lt;td&gt;Check the ticker with &lt;code&gt;search_symbols&lt;/code&gt;.&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;p&gt;To revoke a token, open &lt;strong&gt;Settings → Agent access → Revoke&lt;/strong&gt;. It takes effect on the next request.&lt;/p&gt;
&lt;p&gt;For what the rest of TraderBuddy does, see &lt;a href=&quot;https://smkabir.com/posts/traderbuddy-features/&quot;&gt;Everything TraderBuddy does&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Written for TraderBuddy 0.18.0, 18 October 2026.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>Tech</category><category>traderbuddy</category><category>mcp</category></item><item><title>How smkabir.com stays up: releases, pointers and a failover that is still on paper</title><link>https://smkabir.com/posts/how-smkabir-com-stays-up/</link><guid isPermaLink="true">https://smkabir.com/posts/how-smkabir-com-stays-up/</guid><description>How the site is served from a homelab: R2 releases, two pointers, what happens when things fail, and the failover Worker that is designed but not built yet.</description><pubDate>Mon, 12 Oct 2026 09:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This site is static files served by two small nginx pods in a homelab. That is a deliberately boring design, and it makes the interesting question easy to state: what keeps the site up when something breaks? This post is the honest answer, including the part that does not exist yet.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Built and running:&lt;/strong&gt; content that lives outside the container image, release and pointer handling in object storage, pods that follow the pointer, verified publishing, rollback, and two replicas on different hosts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Designed, not built:&lt;/strong&gt; the failover Worker that would serve a copy of the site from object storage when the whole homelab is unreachable. Until it exists, a power cut or an internet outage takes the site down. Everything below says which side of that line it is on.&lt;/p&gt;&lt;/blockquote&gt;
&lt;h2 id=&quot;the-shape-of-it&quot;&gt;The shape of it&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;(Diagram: view it on the website.)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Nothing a visitor requests ever touches a database or a CMS. Pages are files. That is why the failure modes are few and the recovery is mostly &amp;quot;serve the previous files&amp;quot;.&lt;/p&gt;
&lt;h2 id=&quot;the-content-is-not-in-the-image&quot;&gt;The content is not in the image&lt;/h2&gt;
&lt;p&gt;The container image holds nginx and a small sync program, and &lt;strong&gt;no site content at all&lt;/strong&gt;. The site itself is a release: a folder in an object-storage bucket named after the build, such as &lt;code&gt;releases/c280dba-38211385696/&lt;/code&gt;. Two small text files point into it:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;&lt;code&gt;LATEST&lt;/code&gt;&lt;/strong&gt; names the release the pods should serve.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;&lt;code&gt;VERIFIED&lt;/code&gt;&lt;/strong&gt; names the release that was actually seen serving correctly on the real site.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;A new post is therefore not a deployment. There is no commit to the cluster repository, no image build and no rollout. A publish uploads a folder and moves a pointer.&lt;/p&gt;
&lt;h2 id=&quot;how-a-pod-follows-the-pointer&quot;&gt;How a pod follows the pointer&lt;/h2&gt;
&lt;p&gt;Each pod runs a sidecar that does the same small loop every 60 seconds:&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;Read &lt;code&gt;LATEST&lt;/code&gt;. If it is empty, unreadable or not a plain id, do nothing and keep serving.&lt;/li&gt;&lt;li&gt;If it is already the live release, stop.&lt;/li&gt;&lt;li&gt;Download the release next to the live one, never over it.&lt;/li&gt;&lt;li&gt;Refuse it unless &lt;code&gt;index.html&lt;/code&gt; exists and &lt;code&gt;version.json&lt;/code&gt; says the same id as the folder.&lt;/li&gt;&lt;li&gt;Turn the release&amp;#39;s redirect list into an nginx map using a strict character allow-list, so a bad entry can never inject configuration.&lt;/li&gt;&lt;li&gt;Swap the &lt;code&gt;current&lt;/code&gt; symlink in one atomic rename, ask nginx to reload, and delete everything but the live release and the previous one.&lt;/li&gt;&lt;/ol&gt;
&lt;p&gt;If any step fails, the previous release keeps serving. A visitor never sees a half-written site, and R2 being down means &amp;quot;keep what you have&amp;quot;.&lt;/p&gt;
&lt;h2 id=&quot;a-release-from-push-to-live&quot;&gt;A release, from push to live&lt;/h2&gt;
&lt;p&gt;The publish job is one pipeline, and its order is the point:&lt;/p&gt;
&lt;ol&gt;&lt;li&gt;&lt;strong&gt;Build and gate.&lt;/strong&gt; Formatting, type checks, tests, a privacy scan (no GPS in images, no addresses or phone numbers), the build, and checks on the output: no inline scripts, correct language on every page, no drafts anywhere, every link resolves.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Upload&lt;/strong&gt; to &lt;code&gt;releases/&amp;lt;id&amp;gt;/&lt;/code&gt; and re-verify the upload. A release is immutable: the same id with different content is refused, and re-running is a safe no-op.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Move &lt;/strong&gt;&lt;strong&gt;&lt;code&gt;LATEST&lt;/code&gt;&lt;/strong&gt;&lt;strong&gt;.&lt;/strong&gt; The pods start syncing within a minute.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Wait until the public site reports the new id.&lt;/strong&gt; This polls the real URL for up to ten minutes.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Move &lt;/strong&gt;&lt;strong&gt;&lt;code&gt;VERIFIED&lt;/code&gt;&lt;/strong&gt;, purge Cloudflare&amp;#39;s cache and prune releases beyond the last twenty.&lt;/li&gt;&lt;/ol&gt;
&lt;p&gt;If step 4 never succeeds, the run fails loudly, &lt;code&gt;VERIFIED&lt;/code&gt; does not move, and the live site is untouched.&lt;/p&gt;
&lt;h2 id=&quot;why-there-are-two-pointers&quot;&gt;Why there are two pointers&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;LATEST&lt;/code&gt; says what to serve. &lt;code&gt;VERIFIED&lt;/code&gt; says what is &lt;strong&gt;known to work&lt;/strong&gt;. They are separate because the thing you want to fall back to must never be a release nobody has looked at. A bad build can become &lt;code&gt;LATEST&lt;/code&gt; for a minute; it cannot become &lt;code&gt;VERIFIED&lt;/code&gt;, because &lt;code&gt;VERIFIED&lt;/code&gt; only moves after the live site has reported that exact id. Any fallback reads &lt;code&gt;VERIFIED&lt;/code&gt;, so a fallback is never a gamble.&lt;/p&gt;
&lt;h2 id=&quot;what-happens-when-something-fails&quot;&gt;What happens when something fails&lt;/h2&gt;
&lt;table&gt;&lt;tr&gt;&lt;th&gt;What fails&lt;/th&gt;&lt;th&gt;What happens&lt;/th&gt;&lt;th&gt;State&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;A bad or half-uploaded release&lt;/td&gt;&lt;td&gt;The sidecar rejects it; the previous release keeps serving. &lt;code&gt;LATEST&lt;/code&gt; only moves after the upload is verified.&lt;/td&gt;&lt;td&gt;Built, tested&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;R2 is unreachable&lt;/td&gt;&lt;td&gt;Running pods keep serving what they have. A pod that has to start from scratch stays unready until it can fetch, so it takes no traffic.&lt;/td&gt;&lt;td&gt;Built, tested&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;One pod dies&lt;/td&gt;&lt;td&gt;The other serves. Two replicas on different hosts, at least one always available, and a rolling update never drops below that.&lt;/td&gt;&lt;td&gt;Built, running&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;A node is lost&lt;/td&gt;&lt;td&gt;Pods are rescheduled. Spread is soft on purpose, so losing one host cannot leave a replica stuck pending.&lt;/td&gt;&lt;td&gt;Built&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;The CI check cannot reach the site&lt;/td&gt;&lt;td&gt;The job fails, &lt;code&gt;VERIFIED&lt;/code&gt; does not move, the live site is untouched.&lt;/td&gt;&lt;td&gt;Built, seen in production&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;The whole homelab is unreachable (power, ISP)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;The site is down.&lt;/strong&gt; Publishing also stops.&lt;/td&gt;&lt;td&gt;Not covered yet&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Cloudflare itself has an outage&lt;/td&gt;&lt;td&gt;The site is unreachable. Nothing here helps.&lt;/td&gt;&lt;td&gt;Out of scope&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;h2 id=&quot;the-failover-that-is-not-built-yet&quot;&gt;The failover that is not built yet&lt;/h2&gt;
&lt;p&gt;When the tunnel has no healthy connectors, Cloudflare answers with an error page, and its edge cache only helps on points of presence that happen to be warm. The design is a small Worker in front of the site:&lt;/p&gt;
&lt;p&gt;&lt;em&gt;(Diagram: view it on the website.)&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Reads only.&lt;/strong&gt; Anything other than &lt;code&gt;GET&lt;/code&gt; or &lt;code&gt;HEAD&lt;/code&gt; goes straight to the origin; the Worker never invents a write.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;The mirror is &lt;/strong&gt;&lt;strong&gt;&lt;code&gt;VERIFIED&lt;/code&gt;&lt;/strong&gt;&lt;strong&gt;.&lt;/strong&gt; It is the same bucket the pods read, so there is no second copy to keep in sync, and it never serves an unverified build.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Same headers.&lt;/strong&gt; The security headers come from one source file that renders both the nginx configuration and the Worker, so a mirrored response carries the same policy as an origin one.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Visible.&lt;/strong&gt; Mirror responses carry &lt;code&gt;X-Served-From: mirror&lt;/code&gt;, and an external uptime probe is meant to alert when it appears.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;What still fails when the homelab is down:&lt;/strong&gt; publishing and previews. Reading, the resume, search and the feeds keep working, because they are files.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;The drill:&lt;/strong&gt; scale the deployment to zero through the cluster repository (Argo CD would undo a manual change within seconds), check the key pages and the header, then revert the commit.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;It is on paper because the site only went live today and the failover is the next unit of work, not because it is hard. The release layout was chosen so that it is a small Worker and not a second system.&lt;/p&gt;
&lt;h2 id=&quot;rolling-back&quot;&gt;Rolling back&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Content:&lt;/strong&gt; a manual workflow takes a build id, checks the release exists and is consistent, and rewrites &lt;code&gt;LATEST&lt;/code&gt; and &lt;code&gt;VERIFIED&lt;/code&gt; to it. The pods follow within a minute.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Code&lt;/strong&gt; (the image): a tag is never moved. A fix is the next patch, and a rollback is reverting the commit that bumped the tag in the cluster repository.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;what-has-been-proven-and-what-has-not&quot;&gt;What has been proven, and what has not&lt;/h2&gt;
&lt;p&gt;Proven in production in the last day:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Object storage:&lt;/strong&gt; uploads, pointer moves, pruning, and pods reading with a read-only token. The token that can write is a different token, held only by CI.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;GitHub Actions:&lt;/strong&gt; the content pipeline and the image release, including a vulnerability scan that stopped a release for a HIGH finding in a library the image did not need.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;The cluster path:&lt;/strong&gt; Argo CD sync, two ready pods on different hosts, the gateway route and the tunnel to the apex.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Tests that exist:&lt;/strong&gt; 61 image checks (sidecar, nginx under the cluster&amp;#39;s restrictions, redirects), 35 release-script checks, an end-to-end run of the real site through nginx, and unit tests.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;Not proven: the failover Worker (it does not exist), a real power cut, and a sustained outage of the object store.&lt;/p&gt;
&lt;h2 id=&quot;the-wall-ci-hit-on-day-one&quot;&gt;The wall CI hit on day one&lt;/h2&gt;
&lt;p&gt;The check in step 4 above failed for ten minutes with &amp;quot;last saw nothing&amp;quot;. The site was serving the right release; the CI runner was being handed Cloudflare&amp;#39;s &lt;strong&gt;&amp;quot;Just a moment…&amp;quot;&lt;/strong&gt; challenge page, because Bot Fight Mode treats a datacenter address as a bot. The fix was one switch. The more useful change was to the script: it now reports the HTTP status and the first bytes of what it received, instead of collapsing a challenge page, a 404 and a dead tunnel into the same word. A failure message that cannot distinguish its causes is a bug in the failure message.&lt;/p&gt;
&lt;h2 id=&quot;what-i-would-still-fix&quot;&gt;What I would still fix&lt;/h2&gt;
&lt;ol&gt;&lt;li&gt;&lt;strong&gt;Build the failover Worker.&lt;/strong&gt; It is the one gap in the table above.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Alert from outside the house.&lt;/strong&gt; In-cluster monitoring goes dark with the cluster; a probe on a Worker cron does not.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Move content authoring into the CMS and publish through it,&lt;/strong&gt; so that the pipeline above is triggered by a publish rather than by a commit.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Cache the HTML at the edge.&lt;/strong&gt; Today every page view reaches the homelab; the cache purge on publish is already in place.&lt;/li&gt;&lt;/ol&gt;</content:encoded><category>Tech</category><category>homelab</category><category>kubernetes</category><category>cloudflare</category><category>resilience</category></item><item><title>TraderBuddy architecture: the stack and the constraints behind it</title><link>https://smkabir.com/posts/traderbuddy-architecture/</link><guid isPermaLink="true">https://smkabir.com/posts/traderbuddy-architecture/</guid><description>What TraderBuddy is made of, and the constraints that shaped it: one WebSocket, thirty symbols, SIP versus IEX, and alerts that need previous state.</description><pubDate>Mon, 12 Oct 2026 08:20:00 GMT</pubDate><content:encoded>&lt;p&gt;What the system is made of, and the handful of constraints that explain why it is shaped this way rather than some more obvious way.&lt;/p&gt;
&lt;h2 id=&quot;what-traderbuddy-is&quot;&gt;What TraderBuddy is&lt;/h2&gt;
&lt;p&gt;A self-hosted trading dashboard for about five people: live quotes, server-computed indicators, drawing tools, price alerts delivered to Slack, macro context, and a manual portfolio tracker. It runs on a three-node Talos cluster on Proxmox, behind Cloudflare.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;It tracks and alerts. It never trades.&lt;/strong&gt; There is no order entry, no broker connection, no paper trading — even though the charting frontend is forked from a project that had all three. The portfolio is a manual ledger you type into. This is a product decision, not a missing feature.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;&lt;em&gt;(Diagram: view it on the website.)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Components and the traffic between them.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;the-stack&quot;&gt;The stack&lt;/h2&gt;
&lt;table&gt;&lt;tr&gt;&lt;th&gt;Layer&lt;/th&gt;&lt;th&gt;Choice&lt;/th&gt;&lt;th&gt;Why this one&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Backend&lt;/td&gt;&lt;td&gt;Python 3.12, FastAPI, SQLAlchemy 2.0 async, asyncpg&lt;/td&gt;&lt;td&gt;Async throughout because most request time is provider I/O. &lt;code&gt;mypy --strict&lt;/code&gt; over all of &lt;code&gt;api/src&lt;/code&gt;.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Frontend&lt;/td&gt;&lt;td&gt;Vite 6, React 19, TypeScript, TanStack Query, zustand&lt;/td&gt;&lt;td&gt;A fork of OpenCharts (MIT), pruned to charting only.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Charts&lt;/td&gt;&lt;td&gt;lightweight-charts, pinned at 4.2.3&lt;/td&gt;&lt;td&gt;Pinned exactly, with &lt;code&gt;attributionLogo&lt;/code&gt; on — Apache-2.0 attribution is a licence condition, not a preference.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Tables&lt;/td&gt;&lt;td&gt;@tanstack/react-table 8.21.3&lt;/td&gt;&lt;td&gt;Pinned to v8 on purpose; see &amp;quot;Deliberate deviations&amp;quot; below.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Database&lt;/td&gt;&lt;td&gt;PostgreSQL via CloudNativePG&lt;/td&gt;&lt;td&gt;Everything durable. Sessions and queues included.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Cache&lt;/td&gt;&lt;td&gt;Valkey&lt;/td&gt;&lt;td&gt;Short-TTL caching and rate-limit buckets only. Nothing here is a source of truth.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Identity&lt;/td&gt;&lt;td&gt;Zitadel OIDC, BFF session pattern&lt;/td&gt;&lt;td&gt;The browser holds an opaque signed cookie. No JWT ever reaches it.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Indicators&lt;/td&gt;&lt;td&gt;pandas-ta-classic, pinned exactly&lt;/td&gt;&lt;td&gt;Computed in Python only. The frontend never calculates.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Market data&lt;/td&gt;&lt;td&gt;Alpaca (bars, quotes, news, corporate actions), Finnhub (fundamentals, earnings), FRED + Shiller (macro)&lt;/td&gt;&lt;td&gt;All free tiers. All keys server-side.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Cluster&lt;/td&gt;&lt;td&gt;Talos v1.14, Kubernetes v1.36, Cilium v1.20 Gateway API&lt;/td&gt;&lt;td&gt;Gateway API, not Ingress and not Traefik.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Delivery&lt;/td&gt;&lt;td&gt;GitHub Actions → GHCR → Argo CD&lt;/td&gt;&lt;td&gt;Two repositories; see Deploy.&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;h2 id=&quot;four-workloads-one-image&quot;&gt;Four workloads, one image&lt;/h2&gt;
&lt;p&gt;The Python codebase ships as a single &lt;code&gt;tb-api&lt;/code&gt; image with four entrypoints. Each Deployment runs a different &lt;code&gt;command&lt;/code&gt;. There are deliberately not four backend images.&lt;/p&gt;
&lt;table&gt;&lt;tr&gt;&lt;th&gt;Workload&lt;/th&gt;&lt;th&gt;Entrypoint&lt;/th&gt;&lt;th&gt;Shape&lt;/th&gt;&lt;th&gt;Does&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;tb-api&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;entrypoints/api.py&lt;/code&gt;&lt;/td&gt;&lt;td&gt;2 replicas&lt;/td&gt;&lt;td&gt;The HTTP API and the &lt;code&gt;/ws&lt;/code&gt; fan-out&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;tb-stream&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;entrypoints/stream.py&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;1 replica, &lt;/strong&gt;&lt;strong&gt;&lt;code&gt;Recreate&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Holds the single upstream Alpaca WebSocket and republishes to Valkey pub/sub&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;tb-alerts&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;entrypoints/alerts.py&lt;/code&gt;&lt;/td&gt;&lt;td&gt;scheduled&lt;/td&gt;&lt;td&gt;Evaluates price alerts after the close&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;tb-ingest&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;entrypoints/ingest.py&lt;/code&gt;&lt;/td&gt;&lt;td&gt;scheduled&lt;/td&gt;&lt;td&gt;Symbols, bar retention, macro, portfolio snapshots, digest, scan&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;h2 id=&quot;the-constraints-that-shaped-everything&quot;&gt;The constraints that shaped everything&lt;/h2&gt;
&lt;h3 id=&quot;one-websocket-thirty-symbols&quot;&gt;One WebSocket, thirty symbols&lt;/h3&gt;
&lt;p&gt;Alpaca&amp;#39;s free plan allows &lt;strong&gt;one&lt;/strong&gt; WebSocket connection per account and 30 subscribed symbols. That single fact produces most of the streaming design:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;code&gt;tb-stream&lt;/code&gt; is a singleton with &lt;code&gt;strategy: Recreate&lt;/code&gt;, because two pods briefly coexisting means a &lt;code&gt;406 connection limit exceeded&lt;/code&gt; and no live prices for anyone.&lt;/li&gt;&lt;li&gt;Users express &lt;em&gt;interest&lt;/em&gt; in symbols; a budget allocator picks which 30 get the live feed, holding a symbol for 60 seconds after interest drops so a tab switch doesn&amp;#39;t thrash subscriptions.&lt;/li&gt;&lt;li&gt;Symbols outside the budget are polled from a cached snapshot about every 15 seconds and labelled &lt;strong&gt;delayed&lt;/strong&gt; in the UI rather than silently shown as live.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;(Diagram: view it on the website.)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;One upstream connection, fanned out through Valkey to every browser.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;real-time-is-iex-history-is-sip&quot;&gt;Real-time is IEX; history is SIP&lt;/h3&gt;
&lt;p&gt;Live quotes come from IEX, which is a few percent of US volume. Historical bars come from SIP, which is consolidated and authoritative — but the free plan &lt;strong&gt;embargoes the last 15 minutes of SIP&lt;/strong&gt;, and rejects the &lt;em&gt;entire request&lt;/em&gt; with a 403 if &lt;code&gt;end&lt;/code&gt; falls inside that window rather than trimming it. &lt;code&gt;AlpacaClient.get_bars&lt;/code&gt; clamps a SIP &lt;code&gt;end&lt;/code&gt; clear of the window for that reason.&lt;/p&gt;
&lt;p&gt;The consequence users see: an intraday chart can disagree with an alert until the close, because alerts evaluate SIP bars afterwards. The UI labels which feed a chart is showing instead of pretending they&amp;#39;re the same.&lt;/p&gt;
&lt;h3 id=&quot;alerts-need-previous-state&quot;&gt;Alerts need previous state&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Condition alerts&lt;/strong&gt; (a moving-average cross, a new high, a volume spike, RSI, a percent move) answer a question about a whole session rather than watching 1-minute bars for a touch, so they take their own path: &lt;code&gt;alerts/conditions.py&lt;/code&gt; is pure — daily bars in, a verdict out — and calls the same pandas-ta functions that draw the chart&amp;#39;s indicators, so an alert cannot disagree with the line the user is looking at. Daily bars are fetched split-adjusted; level alerts stay on raw prices because a level is a raw price. A condition has no price, so &lt;code&gt;alerts.level&lt;/code&gt; is nullable, held honest by CHECK constraints, and the chart never draws a line for one. A cross is two consecutive sessions on opposite sides of the line, which is where &amp;quot;previous state&amp;quot; comes from without storing any.&lt;/p&gt;
&lt;p&gt;An alert fires on a &lt;em&gt;crossing&lt;/em&gt;, never on &lt;code&gt;value &amp;gt; threshold&lt;/code&gt;. That distinction requires knowing the previous close, which is why alerts are evaluated once per trading day, server-side, on the session&amp;#39;s SIP 1-minute bars — never on ticks, never in the browser. Dragging an alert line past the current price doesn&amp;#39;t fire it: the baseline resets and only bars after the edit count.&lt;/p&gt;
&lt;h3 id=&quot;money-is-decimal-and-strings-on-the-wire&quot;&gt;Money is Decimal, and strings on the wire&lt;/h3&gt;
&lt;p&gt;Money and share quantities are &lt;code&gt;Decimal&lt;/code&gt; in Python and &lt;strong&gt;strings&lt;/strong&gt; in JSON, never JSON numbers — &lt;code&gt;1234.56&lt;/code&gt; is a float to every client. The frontend formats them and does no arithmetic on them.&lt;/p&gt;
&lt;p&gt;This is not pedantry. The portfolio ledger&amp;#39;s property-based test caught a real bug this way: &lt;code&gt;quantity * (price − avg)&lt;/code&gt; and &lt;code&gt;quantity * price − quantity * avg&lt;/code&gt; round differently once average cost is a repeating decimal, and the books failed to balance by one ulp. Replay now quantizes to the column precisions, so what it computes is exactly what Postgres stores.&lt;/p&gt;
&lt;h3 id=&quot;privacy-and-its-one-exception&quot;&gt;Privacy, and its one exception&lt;/h3&gt;
&lt;p&gt;Portfolios, alerts and watchlists are scoped to the session user — someone else&amp;#39;s id returns &lt;strong&gt;404, not 403&lt;/strong&gt;, so the API never confirms which ids exist. Notifications go only to destinations that user connected themselves — their own Slack (a direct message, or a channel they choose), Telegram chat or webhook. The &lt;em&gt;shared&lt;/em&gt; Slack bot is DM-only: its &lt;code&gt;post()&lt;/code&gt; refuses anything but a DM, because a channel would show every user everyone else&amp;#39;s holdings. A user-owned Slack token is different — workspace, app and destination are all theirs — so it may post to a channel they pick, and the UI says plainly that everyone in it can read the alerts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Accounts.&lt;/strong&gt; Zitadel authenticates; TraderBuddy authorizes. Registration is open, but every new user is &lt;code&gt;pending&lt;/code&gt; and can read only &lt;code&gt;/api/me&lt;/code&gt; until an admin approves them — over HTTP and over the WebSocket, which authenticates separately and needed its own check. &lt;code&gt;projectRoleCheck&lt;/code&gt; is off in Zitadel precisely so a newcomer gets a waiting screen rather than an error page. The &lt;code&gt;admin&lt;/code&gt; role still comes from Zitadel&amp;#39;s project-role claim.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Credentials users enter.&lt;/strong&gt; Slack and Telegram tokens and webhook URLs are encrypted at rest with Fernet (&lt;code&gt;CREDENTIAL_KEY&lt;/code&gt;), as a whole blob rather than selected fields, because a webhook URL can carry a token in its path. The API is write-only for them — nothing returns a credential, so there is no &amp;quot;show token&amp;quot; — and no admin route touches the table. Webhooks are the one feature that lets a user aim the server at an address of their choosing, in a cluster that also runs Zitadel, Postgres and the Kubernetes API, and &lt;strong&gt;egress is not restricted&lt;/strong&gt;, so the guard is the only defence: HTTPS only, every resolved address checked, then the connection made to the address that was checked rather than the name (a second DNS lookup would allow rebinding), no redirects.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The one exception, decided 2026-10-03: a user holding the Zitadel &lt;code&gt;admin&lt;/code&gt; role can read any user&amp;#39;s watchlist and portfolios, and a &lt;em&gt;count&lt;/em&gt; of their active alerts, through &lt;code&gt;/api/admin/*&lt;/code&gt;. It is read-only, it 404s for everyone else, and it carries two conditions — every cross-user read writes an &lt;code&gt;admin_audit&lt;/code&gt; row, and the settings dialog tells &lt;em&gt;every&lt;/em&gt; user this is possible. Searches are not logged anywhere.&lt;/p&gt;&lt;/blockquote&gt;
&lt;h2 id=&quot;request-paths&quot;&gt;Request paths&lt;/h2&gt;
&lt;h3 id=&quot;a-normal-api-call&quot;&gt;A normal API call&lt;/h3&gt;
&lt;p&gt;Browser → Cloudflare → Cilium Gateway → &lt;code&gt;tb-api&lt;/code&gt;. The session cookie resolves against a &lt;code&gt;sessions&lt;/code&gt; row in Postgres. &lt;strong&gt;Zitadel is never called on a normal request&lt;/strong&gt; — no introspection endpoint — because it runs a single replica and would be a hard dependency on every page load. Roles are a snapshot taken at login and refreshed on token refresh.&lt;/p&gt;
&lt;h3 id=&quot;a-live-price&quot;&gt;A live price&lt;/h3&gt;
&lt;p&gt;Alpaca → &lt;code&gt;tb-stream&lt;/code&gt; → Valkey pub/sub → every &lt;code&gt;tb-api&lt;/code&gt; replica → each browser&amp;#39;s &lt;code&gt;/ws&lt;/code&gt;. Fan-out through Valkey is what lets &lt;code&gt;tb-api&lt;/code&gt; scale to two replicas while only one upstream connection exists.&lt;/p&gt;
&lt;h3 id=&quot;a-chart&quot;&gt;A chart&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;GET /api/bars&lt;/code&gt; reads closed bars from the Postgres cache, backfills from Alpaca only when the newest cached bar could be stale, and appends one live forming bar from IEX in two narrow cases. Closed bars are immutable once written.&lt;/p&gt;
&lt;h3 id=&quot;an-agent-call&quot;&gt;An agent call&lt;/h3&gt;
&lt;p&gt;A coding agent speaks MCP to &lt;code&gt;/api/mcp/&lt;/code&gt; with &lt;code&gt;Authorization: Bearer tb_pat_…&lt;/code&gt;. A door in front of the mount checks the token (SHA-256 lookup in Postgres; Zitadel is never involved) and each tool then calls the app&amp;#39;s own REST routes in-process, carrying the same token. So validation, the approval gate, per-user scoping and rate limits are the browser&amp;#39;s, not a copy of them. A token user has no roles, and what a token may reach is a fail-closed method-and-path allowlist in &lt;code&gt;auth/tokens.py&lt;/code&gt;. See Connect a coding agent.&lt;/p&gt;
&lt;h3 id=&quot;the-scanner-snapshot&quot;&gt;The scanner snapshot&lt;/h3&gt;
&lt;p&gt;After each close, &lt;code&gt;tb-ingest scan&lt;/code&gt; fetches split-adjusted SIP daily bars for the shared universe (S&amp;amp;P 500, a few Nasdaq-100 extras, ~100 curated ETFs) plus every symbol anyone watches or holds, computes the moving averages, RSI, highs, volume and cross events once per symbol with the same pandas-ta calls the chart uses, joins cached Finnhub fundamentals, and writes one &lt;code&gt;scan_snapshot&lt;/code&gt; row each. It is all-or-nothing: if a fetch fails the previous night&amp;#39;s snapshot stands. Scans read that table and never a provider. Fundamentals are refreshed 150 symbols a night, paced under half the shared Finnhub budget, so they are up to a week old.&lt;/p&gt;
&lt;p&gt;A scan is a validated list of filters (&lt;code&gt;scanner/filters.py&lt;/code&gt;) compiled to SQL from a field whitelist, so no string a user sends is ever an identifier. Each query is restricted to &lt;code&gt;is_core OR symbol IN (your watchlist and holdings)&lt;/code&gt;: the snapshot contains rows that exist only because one person holds a symbol, and they must never appear for anyone else. &amp;quot;New&amp;quot; is computed by running the same filters on the previous session&amp;#39;s snapshot, which is kept for two weeks, so there is no extra table.&lt;/p&gt;
&lt;p&gt;Once the snapshot is committed the same job sends each user&amp;#39;s notifying scanners (&lt;code&gt;scanner/notify.py&lt;/code&gt;): every scanner of theirs with &lt;code&gt;notify&lt;/code&gt; on is run through the same &lt;code&gt;query.run&lt;/code&gt; as the page, so the privacy restriction is identical, and the matches go out as one message through &lt;code&gt;notify/dispatch.py&lt;/code&gt; to that user&amp;#39;s own channels. A &lt;code&gt;scanner_notifications&lt;/code&gt; row is claimed per user per session before sending, as the digest does, so a rerun never posts twice; a send that fails everywhere releases the claim for the retry.&lt;/p&gt;
&lt;h3 id=&quot;company-financials&quot;&gt;Company financials&lt;/h3&gt;
&lt;p&gt;Statements come from SEC EDGAR, which is free and keyless and which the browser never calls. Every request carries a &lt;code&gt;User-Agent&lt;/code&gt; naming a project contact address, and goes through a Valkey token bucket at 8 requests a second (the SEC allows 10) shared by both API replicas and the nightly job. For a symbol, &lt;code&gt;financials/service.py&lt;/code&gt; serves a stored report younger than a day; otherwise it reads the company&amp;#39;s small submissions list and recomputes only when a new filing accession has appeared or the scoring &lt;code&gt;VERSION&lt;/code&gt; changed. A recompute parses the multi-megabyte &lt;code&gt;companyfacts&lt;/code&gt; document down to the ~30 concepts we map and drops the rest before the next company.&lt;/p&gt;
&lt;p&gt;Companies tag the same line differently and change tags over time, so each field takes the first mapped tag that has a value for each period; annual figures are 345–385-day facts with the latest filing winning restatements, and trailing twelve months is the last fiscal year plus this year&amp;#39;s year-to-date minus last year&amp;#39;s. Indicators and the score are pure functions (&lt;code&gt;indicators.py&lt;/code&gt;, &lt;code&gt;score.py&lt;/code&gt;): a piecewise-linear table per indicator, a pillar is the mean of its indicators, and the overall score is the weighted mean of the pillars that could be scored. Unknown is never zero — a missing input is skipped, and fewer than three scored pillars is no score. The report is stored in &lt;code&gt;financial_reports&lt;/code&gt; with a small &lt;code&gt;scan&lt;/code&gt; block; the nightly scan reads only that block in SQL, never the whole report, which is the memory lesson from 0.4.3. If the SEC is unreachable the stored report is served marked stale.&lt;/p&gt;
&lt;h2 id=&quot;deliberate-deviations-from-the-plan&quot;&gt;Deliberate deviations from the plan&lt;/h2&gt;
&lt;p&gt;Each of these is a place the implementation diverged from &lt;code&gt;TRADERBUDDY-IMPLEMENTATION-rev2.md&lt;/code&gt; on purpose. They are recorded here because an undocumented deviation reads as a bug to the next person.&lt;/p&gt;
&lt;table&gt;&lt;tr&gt;&lt;th&gt;Plan said&lt;/th&gt;&lt;th&gt;Built instead&lt;/th&gt;&lt;th&gt;Why&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;A &lt;code&gt;symbol_profiles&lt;/code&gt; table&lt;/td&gt;&lt;td&gt;One &lt;code&gt;symbol_facts&lt;/code&gt; table with a &lt;code&gt;kind&lt;/code&gt; discriminator&lt;/td&gt;&lt;td&gt;Profile, metrics and recommendations have identical shape and TTL. Three identical tables would be three places to change.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Baseline mode &amp;quot;swaps the candle series&amp;quot;&lt;/td&gt;&lt;td&gt;Hides the candles, adds a baseline series&lt;/td&gt;&lt;td&gt;The drawing manager, crosshair legend and bid/ask lines all hold a reference to the candle series. Removing it would leave them pointing at a disposed object.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;@tanstack/react-table&lt;/code&gt;, version unspecified&lt;/td&gt;&lt;td&gt;Pinned to 8.21.3, not the current 9.2.4&lt;/td&gt;&lt;td&gt;v9 is a ground-up redesign (atom stores, a &lt;code&gt;Subscribe&lt;/code&gt; HOC). A sortable positions table isn&amp;#39;t worth guessing at an unfamiliar API.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Fall back to the last regular-session 1-minute bar if the SIP daily close includes extended hours&lt;/td&gt;&lt;td&gt;Use the daily close directly&lt;/td&gt;&lt;td&gt;Measured: it &lt;em&gt;excludes&lt;/em&gt; extended hours. AAPL printed above its regular high after the bell and the daily bar still matched the regular session exactly. The daily close is the official consolidated close — the fallback would have been a downgrade.&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Watchlist truncated when the Slack block budget runs short&lt;/td&gt;&lt;td&gt;Capped at 15 rows regardless&lt;/td&gt;&lt;td&gt;Slack&amp;#39;s 50-block limit alone would allow ~1,600 rows. The cap exists because a DM listing forty symbols is unreadable.&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;h2 id=&quot;standing-risks&quot;&gt;Standing risks&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;&lt;code&gt;tb-stream&lt;/code&gt;&lt;/strong&gt;&lt;strong&gt; is a single point of failure.&lt;/strong&gt; The free tier offers no fix. It is monitored, not solved.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Losing the &lt;/strong&gt;&lt;strong&gt;&lt;code&gt;pve2&lt;/code&gt;&lt;/strong&gt;&lt;strong&gt; host takes everything&lt;/strong&gt; — etcd majority and two of three Postgres instances. The real fix is a third host, which doesn&amp;#39;t exist.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Provider free tiers can change without notice.&lt;/strong&gt; The provider layer is built so swapping one is a config change.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Five users share one quota.&lt;/strong&gt; A sixth may need paid tiers, not more RAM.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Nothing here is investment infrastructure.&lt;/strong&gt; Analyst consensus is opinion data; indicator crossings are not predictions.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Last updated 2026-10-06. The diagrams above are drawn from D2 source files kept with the project.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>Tech</category><category>traderbuddy</category><category>architecture</category><category>homelab</category><category>kubernetes</category></item><item><title>Everything TraderBuddy does</title><link>https://smkabir.com/posts/traderbuddy-features/</link><guid isPermaLink="true">https://smkabir.com/posts/traderbuddy-features/</guid><description>A self-hosted terminal for the US market: a watchlist home, server-side indicators, after-close alerts, a screener, SEC financials, news with an AI read, an AI research assistant and a manual portfolio. It never trades.</description><pubDate>Mon, 12 Oct 2026 08:10:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;It tracks and alerts. It never trades.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A self-hosted terminal for the US market: a watchlist that opens on the day&amp;#39;s moves, charts with every indicator computed on the server, alerts that reach your own Slack or Telegram after the close, a screener over ~600 symbols, company financials read straight from SEC filings, market-moving news with an AI&amp;#39;s read of each item, an AI research assistant on your own key, and a portfolio you keep by hand. No broker connection, no order entry, and nothing about you sold to anyone.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;(Interactive content: An example TraderBuddy chart: daily candles with a server-computed moving average, a price alert level drawn at 230 with its bell enabled, and a dashed average-cost line at 214 for a symbol held in a portfolio.. View it on the website.)&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;your-watchlist-and-a-page-for-every-stock&quot;&gt;Your watchlist, and a page for every stock&lt;/h2&gt;
&lt;p&gt;TraderBuddy opens on your watchlist. Each stock shows its price, the day&amp;#39;s move and a small chart of the last session, with SPY, QQQ, DIA and IWM above the list for the market&amp;#39;s mood. Sort by your own order, % change or A–Z.&lt;/p&gt;
&lt;figure&gt;&lt;img src=&quot;https://smkabir.com/images/traderbuddy/watchlist-1600.webp&quot; alt=&quot;The watchlist with a small chart and the day&amp;#39;s move for each stock, beside the page for one stock.&quot; width=&quot;1600&quot; height=&quot;760&quot;&gt;&lt;figcaption&gt;The watchlist beside the page for one stock (META): its alerts, key stats, analyst ratings and financial-health summary. Each row shows the day&amp;#39;s move and a small chart of the session.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;It knows what is yours.&lt;/strong&gt; A dot marks what you hold and a bell marks stocks with active alerts.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Fresh while it matters.&lt;/strong&gt; Prices refresh every 15 seconds while the market is open. When it is closed, the list says it is showing the last close.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;A page for every stock&lt;/strong&gt; (&lt;code&gt;/stock/NVDA&lt;/code&gt;): the price and day change, a chart with 1D to 5Y ranges, what you hold in it and your alerts on it, market cap, P/E, dividend yield, beta, the 52-week range, analyst ratings, a financial-health summary and its latest news. &lt;strong&gt;Watch&lt;/strong&gt;, &lt;strong&gt;Add alert&lt;/strong&gt; and &lt;strong&gt;Ask AI&lt;/strong&gt; are one tap from the top.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Made for a thumb.&lt;/strong&gt; Swipe a row left to remove it, and Edit reorders with arrows. An empty list offers SPY, QQQ, AAPL, MSFT and NVDA in one tap each.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;the-chart&quot;&gt;The chart&lt;/h2&gt;
&lt;p&gt;The full drawing chart is the &lt;strong&gt;Chart&lt;/strong&gt; tab. Open a symbol and you get candles from one minute to one week, drawn from closed bars cached on the server with one live forming bar on top. Scroll back and deeper history loads.&lt;/p&gt;
&lt;figure&gt;&lt;img src=&quot;https://smkabir.com/images/traderbuddy/chart-1600.webp&quot; alt=&quot;The chart page with NVDA candles, drawing tools and a watchlist panel.&quot; width=&quot;1600&quot; height=&quot;667&quot;&gt;&lt;figcaption&gt;The Chart tab: NVDA on 15-minute candles, the drawing tools on the left and the watchlist panel on the right.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Indicators, computed server-side.&lt;/strong&gt; SMA, EMA, RSI, ATR, VWAP, MACD, Bollinger Bands and Stochastic. Your browser draws them; it never calculates them, so the chart and an alert can never disagree.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Drawing tools.&lt;/strong&gt; Trendlines, horizontals, rectangles and text, with magnet snapping, undo and redo, and an object tree. Everything is saved per symbol and waiting when you come back.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Live where it counts.&lt;/strong&gt; Real-time prices stream for the symbols you have open. The rest are polled and labelled &lt;strong&gt;delayed&lt;/strong&gt;, so you always know which number you are looking at.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Baseline mode.&lt;/strong&gt; Show closes against a reference instead of in absolute terms: since you added the symbol, 1W, 1M, 3M, YTD or 1Y. The legend names the reference price and its date.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Info and news beside the chart.&lt;/strong&gt; Profile, P/E, P/B, dividend yield, beta, a 52-week range bar and analyst ratings, marked as opinion. News follows your watchlist and falls back to market news when it is empty.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Symbols you hold are marked.&lt;/strong&gt; A dashed average-cost line on the chart and a dot in the watchlist, so a position never gets lost between pages.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Search from anywhere.&lt;/strong&gt; &lt;code&gt;⌘K&lt;/code&gt; finds any symbol. &lt;code&gt;↵&lt;/code&gt; opens its page, &lt;code&gt;⌘↵&lt;/code&gt; adds it to your watchlist. What you type is never logged.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Usable on a phone.&lt;/strong&gt; The chart keeps the screen: the toolbar is two rows, with Watchlist, News and Info together under one Panels button. Watchlist, Chart, Alerts and Portfolio are tabs along the bottom, and the rest, Scanner included, are under More.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Dark, light, or your device&amp;#39;s choice.&lt;/strong&gt; Pick the palette in Settings; it stays on that device. A market clock in the top bar says whether the market is open and when that changes.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;alerts&quot;&gt;Alerts&lt;/h2&gt;
&lt;p&gt;Draw a line where you care about the price, turn on its bell, and TraderBuddy watches that level for you. Or create one from the Alerts page, where every alert you own is listed, editable, renewable and deletable.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;(Interactive content: How an alert travels: your alert, then after the US close the session’s one-minute bars are read once a day, and only if the previous close sat on the other side of your level does a message go to the channels you connected.. View it on the website.)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The timing is the part worth knowing before you rely on it: alerts are checked &lt;strong&gt;once per trading day, after the US close&lt;/strong&gt;, on that session&amp;#39;s one-minute bars. That is a deliberate trade — the real-time budget belongs to the charts you have open — and it means an alert tells you on the evening of the day something happened, not the second it happens. The single exception is opening movers, below.&lt;/p&gt;
&lt;figure&gt;&lt;img src=&quot;https://smkabir.com/images/traderbuddy/alerts-1600.webp&quot; alt=&quot;The Alerts page: a table of active alerts with their conditions and notes.&quot; width=&quot;1600&quot; height=&quot;696&quot;&gt;&lt;figcaption&gt;The Alerts page: each alert with its condition, your note, whether it repeats, when it last fired and when it expires.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Price levels.&lt;/strong&gt; Crosses above, below, or either. On a trendline, the level is read along the line at the moment of checking, so a sloping level stays correct as time passes.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Conditions, not just levels.&lt;/strong&gt; A close crossing its SMA or EMA, a golden or death cross, a daily move of X%, a new 52-week or all-time high or low, a volume spike, or RSI crossing a level. Any symbol in the universe, ETFs included.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Told what happened.&lt;/strong&gt; A condition alert says what occurred rather than quoting a number: &amp;quot;the 50-day crossed above the 200-day&amp;quot;, not &amp;quot;value &amp;gt; threshold&amp;quot;.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Your channels, your credentials.&lt;/strong&gt; A Slack DM, a Slack channel you pick, a Telegram chat, or a signed webhook. Every enabled channel gets every alert, and one failing never costs you the others.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;A lifecycle, not a graveyard.&lt;/strong&gt; Alerts last 90 days and then wait for you to renew them, re-arm after they fire, and keep a per-alert history of what happened and when. Renew or delete in bulk.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;The chart agrees with the server.&lt;/strong&gt; A bell on the chart means a real alert exists on the server. You will never be shown one that isn&amp;#39;t actually armed.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;scanner&quot;&gt;Scanner&lt;/h2&gt;
&lt;p&gt;Filter about 600 symbols — the S&amp;amp;P 500, Nasdaq-100 names, roughly 100 ETFs, plus anything on your own watchlist or in your portfolio — on price moves, the 52-week range, volume against its average, SMA 20/50/200, RSI, golden and death crosses, new highs and lows, P/E, market cap and financial health. Thirteen scans are built in. Duplicate one to edit it, or save up to 25 of your own.&lt;/p&gt;
&lt;figure&gt;&lt;img src=&quot;https://smkabir.com/images/traderbuddy/scanner-1600.webp&quot; alt=&quot;The scanner page showing the Oversold scanner&amp;#39;s description and its matching stocks.&quot; width=&quot;1600&quot; height=&quot;690&quot;&gt;&lt;figcaption&gt;The built-in Oversold scanner: what it looks for in plain words, then its matches as of the last close, with the new ones counted.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;As of the close, Fri 9 Oct 2026&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What this scanner looks for&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Index:&lt;/strong&gt; S&amp;amp;P 500 — the 500 largest US listed companies.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Change today:&lt;/strong&gt; above 7% or below −7% — a move far outside a normal session.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Volume:&lt;/strong&gt; above 2× its 20-day average — unusual participation, not a quiet drift.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Matches&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;Thu 8 Oct: &lt;code&gt;MRNA&lt;/code&gt; — Volume &lt;strong&gt;2.4×&lt;/strong&gt; its 20-day average (New)&lt;/li&gt;&lt;li&gt;Fri 9 Oct: &lt;code&gt;MRNA&lt;/code&gt; — +14.2% on the day&lt;/li&gt;&lt;li&gt;and twelve more S&amp;amp;P 500 names moved 7% or more that session&lt;/li&gt;&lt;/ul&gt;&lt;/blockquote&gt;
&lt;p&gt;&lt;em&gt;A real match: Moderna&amp;#39;s 8–9 October spike, caught by a 7% move scan and a 2× volume scan.&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Written in plain language.&lt;/strong&gt; Every scanner explains itself as you build it: &amp;quot;Debt / equity: below 1× — total debt over equity; under 1 is a lighter balance sheet.&amp;quot; You can add a description in your own words.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Honest about its clock.&lt;/strong&gt; Results are a snapshot taken after each close, and the page says which close. Symbols that were not matching the session before are marked &lt;strong&gt;New&lt;/strong&gt;.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;It can come to you.&lt;/strong&gt; Turn on &lt;strong&gt;Send matches to my channels&lt;/strong&gt; — new matches only, or every match — and one message per evening lists what each of your scanners found, every symbol linking to its chart. Nothing is sent on a day nothing matched.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;No false precision.&lt;/strong&gt; A symbol whose value is unknown — no 200-day average yet, no P/E — never matches a filter on it, instead of being quietly counted as zero.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;financials&quot;&gt;Financials&lt;/h2&gt;
&lt;p&gt;Search a company and TraderBuddy reads its latest 10-K or 10-Q from the SEC, names the filing it used, links to it, and turns it into one score with the five parts behind it.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;(Interactive content: An example financial health score of 78 out of 100, broken into five pillars drawn on the same scale: profitability 86, cash flow 81, balance sheet 74, shareholders 68 and growth 52.. View it on the website.)&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Every number in a sentence.&lt;/strong&gt; Each indicator carries its value, a five-year trend, a good / fair / weak marker and one plain explanation: &amp;quot;Out of every $100 of revenue, $23 is left after the direct costs.&amp;quot;&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Two checks shown test by test.&lt;/strong&gt; The Piotroski F-score&amp;#39;s nine pass/fail tests and the Altman Z-score&amp;#39;s safe, grey or distress zones, each displayed in full so the score is never a black box.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;The statements themselves.&lt;/strong&gt; Five years of income statement, balance sheet and cash flow, in line names a person recognises.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Unknown is never zero.&lt;/strong&gt; A figure the filing does not report is left out. A part without enough data says so, and a company with too little to go on gets no score at all.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Banks are judged as banks.&lt;/strong&gt; For banks and insurers the measures that do not apply are dropped rather than invented, and the page says which and why.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Not advice.&lt;/strong&gt; The score summarises what a company reported about its past. It does not forecast the share price, and the page says so plainly.&lt;/li&gt;&lt;/ul&gt;
&lt;figure&gt;&lt;img src=&quot;https://smkabir.com/images/traderbuddy/financials-1600.webp&quot; alt=&quot;The financial-health report for NVIDIA: a score of 92 out of 100 and five pillar scores.&quot; width=&quot;1600&quot; height=&quot;764&quot;&gt;&lt;figcaption&gt;The financial-health report for NVIDIA, worked out from its latest 10-Q, with the filing it used named and linked.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;h2 id=&quot;news-and-what-it-probably-means&quot;&gt;News, and what it probably means&lt;/h2&gt;
&lt;p&gt;One page for the posts, filings and headlines that move markets, read from free public sources every five minutes and listed newest first, each with who published it and a link to the original.&lt;/p&gt;
&lt;figure&gt;&lt;img src=&quot;https://smkabir.com/images/traderbuddy/news-1600.webp&quot; alt=&quot;The News page with headlines and an AI read of one item.&quot; width=&quot;1600&quot; height=&quot;818&quot;&gt;&lt;figcaption&gt;The News page: company headlines, and under one of them the AI&amp;#39;s read, labelled as opinion, not advice. The month&amp;#39;s AI spend is shown at the top.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Who it reads.&lt;/strong&gt; Federal Reserve press releases and speeches, White House news, Donald Trump&amp;#39;s Truth Social posts (through the independent archive trumpstruth.org), SEC 8-K filings of S&amp;amp;P 500 companies and the symbols you follow, and Finnhub market and company headlines.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;An AI&amp;#39;s read of each item.&lt;/strong&gt; A model (Claude Haiku) says how much an item matters (low, medium or high), sums it up in a line, and names the companies or sectors it is likely to push up or down, and why, even when the item names no company. It is labelled as &lt;strong&gt;an AI&amp;#39;s opinion, not advice&lt;/strong&gt;, with the direction in words as well as colour.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Read once, for everyone.&lt;/strong&gt; The request contains only the item. Nothing about what you hold or watch leaves TraderBuddy; which items matter to &lt;em&gt;you&lt;/em&gt; is worked out afterwards, on the server.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;A hard cap.&lt;/strong&gt; AI reading stops at $10 a month, checked before every call, and the page shows how much of it is used.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Honest about its sources.&lt;/strong&gt; A source that stops answering is named at the top of the page with when it was last read.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;an-ai-research-assistant&quot;&gt;An AI research assistant&lt;/h2&gt;
&lt;p&gt;Pick a model and ask about one stock or a handful: &lt;em&gt;&amp;quot;Compare AMD and NVDA&amp;quot;&lt;/em&gt;, &lt;em&gt;&amp;quot;What do AAPL&amp;#39;s financials say?&amp;quot;&lt;/em&gt; It looks things up as it goes, and you see each lookup as it happens. It reads prices and indicators, SEC financials, news, macro series, the scanner and your own watchlist, alerts and portfolio, then answers with the evidence, the bull and bear case, what would change its mind, and how confident it is.&lt;/p&gt;
&lt;figure&gt;&lt;img src=&quot;https://smkabir.com/images/traderbuddy/ai-research-1600.webp&quot; alt=&quot;The AI research page with suggested questions and a list of past conversations.&quot; width=&quot;1600&quot; height=&quot;734&quot;&gt;&lt;figcaption&gt;The AI research page: past conversations on the left, suggested questions, and the model picker under the question box.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;It drafts; you decide.&lt;/strong&gt; When it suggests an alert or a watchlist addition it shows a card. Nothing exists until you press &lt;strong&gt;Create&lt;/strong&gt;, and then it goes through the same checks as the Alerts page. It cannot change anything by itself.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Your own OpenRouter key.&lt;/strong&gt; Paste it once; it is encrypted and never shown again. You pay OpenRouter directly for what you use, with a spending meter and an optional monthly cap in Settings. There is no shared key.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Private conversations.&lt;/strong&gt; They are kept and listed so you can return to them or delete them. Not even an admin can read them.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Web search&lt;/strong&gt; is off by default and turned on per message.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;macro-context&quot;&gt;Macro context&lt;/h2&gt;
&lt;p&gt;Eight series on one page: the 10-year and 2-year Treasury yields, the 10y−2y spread, the Fed funds rate, CPI, unemployment, the VIX and the Shiller CAPE. Every figure shows the observation date it belongs to, because a macro number without its date reads as today&amp;#39;s when it can be months old.&lt;/p&gt;
&lt;p&gt;Colour follows meaning rather than direction, and stops where meaning stops:&lt;/p&gt;
&lt;table&gt;&lt;tr&gt;&lt;th&gt;Series&lt;/th&gt;&lt;th&gt;When it rises&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Unemployment, CPI, VIX, Shiller CAPE&lt;/td&gt;&lt;td&gt;Worse — more joblessness, faster inflation, more stress, a more expensive starting point&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;10y−2y spread&lt;/td&gt;&lt;td&gt;Better — it is the &lt;em&gt;fall&lt;/em&gt; through zero that people watch as a recession signal&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;10-year, 2-year, Fed funds&lt;/td&gt;&lt;td&gt;Neutral — higher rates are good for savers and bad for borrowers; picking a colour would be editorialising&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;h2 id=&quot;portfolio&quot;&gt;Portfolio&lt;/h2&gt;
&lt;p&gt;A ledger you keep yourself: buys, sells, dividends, splits and fees, typed in or imported from CSV with a preview and per-row errors first. There is no broker connection, by choice. Positions, profit and loss, and history are all derived by replaying what you entered. The page leads with market value, with today&amp;#39;s change under it.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;(Interactive content: An example portfolio chart with two lines from nightly snapshots: market value rising to 16,480 dollars and cost basis stepping from 10,000 to 14,000 dollars in the month money was added, so a deposit cannot be mistaken for a return.. View it on the website.)&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Arithmetic you can trust.&lt;/strong&gt; Money and quantities are exact decimals end to end, never floating point. Every edit replays the whole ledger, so a backdated sell that would oversell is refused and names the transaction it conflicts with.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Average cost, stated as such.&lt;/strong&gt; Positions use average cost rather than tax lots, and the interface tells you that instead of letting you assume otherwise.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Allocation.&lt;/strong&gt; By symbol or by sector, with &lt;code&gt;Unknown&lt;/code&gt; shown rather than hidden when a sector isn&amp;#39;t available.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Corporate actions flagged.&lt;/strong&gt; Splits and dividends that look unrecorded are pointed out, and &amp;quot;Record it&amp;quot; pre-fills the transaction. Nothing is ever applied to your ledger automatically.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;a-daily-digest-if-you-want-one&quot;&gt;A daily digest, if you want one&lt;/h2&gt;
&lt;p&gt;Off until you turn it on, section by section. After the close on trading days, you get one message on the channels you connected, with any section that has nothing to say left out.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;An example of how it arrives&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Portfolios&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Core · +$412 today, value $16,480 against $14,000 of cost.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Alerts&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;AAPL crossed above 230.00. The alert has re-armed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Watchlist&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;MRNA +14.2% on 2.4× its usual volume.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;News and earnings&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Three headlines on symbols you follow, and who reports tomorrow.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;It is sent at most once a day. A crash can make it skip a day; it can never send you the same day twice.&lt;/p&gt;
&lt;h2 id=&quot;opening-movers&quot;&gt;Opening movers&lt;/h2&gt;
&lt;p&gt;One hour after the US open, TraderBuddy looks for S&amp;amp;P 500 stocks running: up &lt;strong&gt;5% or more&lt;/strong&gt; on yesterday&amp;#39;s close and trading at least &lt;strong&gt;twice their usual volume&lt;/strong&gt; for those first minutes, measured against the same minutes of the last ten sessions, because volume is always heaviest at the open.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;An example of how it arrives&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Two S&amp;amp;P 500 names are running an hour into the session.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;XYZ&lt;/code&gt; +6.4% on 3.1× its usual volume for this part of the morning.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ABC&lt;/code&gt; +5.2% on 2.2×.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Prices are 15 minutes delayed&lt;/strong&gt;&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;This is the one thing in TraderBuddy that runs &lt;em&gt;during&lt;/em&gt; the session, and it is a deliberate exception. It is off until you turn it on in Settings, next to the digest. It arrives on trading days only, at most once a day, and never on a quiet morning. The time is shown in your own time zone, and the message says plainly that its prices are the full-market feed, 15 minutes delayed.&lt;/p&gt;
&lt;h2 id=&quot;let-a-coding-agent-use-it&quot;&gt;Let a coding agent use it&lt;/h2&gt;
&lt;p&gt;Claude Code, Cursor and other MCP clients can look symbols up, read your alerts and portfolios, and manage alerts, your watchlist, drawings, saved scanners, the digest and your portfolio transactions on your behalf. Create a token in Settings, paste the URL into your client, and ask in words: &lt;em&gt;&amp;quot;what is NVDA&amp;#39;s RSI doing, and alert me if it crosses 70&amp;quot;&lt;/em&gt;. The step-by-step setup is in &lt;a href=&quot;https://smkabir.com/posts/traderbuddy-mcp/&quot;&gt;How to connect an AI agent to TraderBuddy over MCP&lt;/a&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &amp;quot;mcpServers&amp;quot;: {
    &amp;quot;traderbuddy&amp;quot;: {
      &amp;quot;url&amp;quot;: &amp;quot;https://trader.smkabir.com/api/mcp/&amp;quot;,
      &amp;quot;headers&amp;quot;: { &amp;quot;Authorization&amp;quot;: &amp;quot;Bearer tb_pat_…&amp;quot; }
    }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A token is shown once, expires between 30 days and a year, and is revoked rather than deleted. It can be read-only or read-and-write. A write token can add and correct portfolio transactions, but never delete one. What it can never touch is fixed in the server, not in the client: your notification credentials, the admin area, deleting from your portfolio, and minting more tokens. And since TraderBuddy has no order entry at all, no agent of yours can place a trade.&lt;/p&gt;
&lt;h2 id=&quot;where-your-data-sits&quot;&gt;Where your data sits&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Your browser talks to one server.&lt;/strong&gt; Only TraderBuddy&amp;#39;s own. No analytics, no third-party widgets, no market-data provider called from your machine. Every provider key stays server-side.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Searches are not recorded.&lt;/strong&gt; Nothing logs what you type into ⌘K or the symbol box. Not for you, not for an admin.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Holdings and alerts are private.&lt;/strong&gt; Every portfolio, alert and watchlist route is scoped to you; someone else&amp;#39;s id simply does not exist. Notifications go to destinations you connected yourself, never a shared channel.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Credentials are write-only.&lt;/strong&gt; Slack and Telegram tokens, webhook secrets and your OpenRouter key are encrypted at rest and never returned by any route, to you or to anyone. To change one you replace it.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;What the AI sees, stated.&lt;/strong&gt; The news reader is sent only the news item. The research assistant, when you use it, sends market data and, when relevant, your watchlist, alerts and portfolio to OpenRouter and the company running the model you chose. TraderBuddy asks for providers that do not keep or train on it, and the AI page says all of this before you paste a key.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;The admin exception, stated.&lt;/strong&gt; An admin can read any user&amp;#39;s watchlist, portfolios and alerts — read-only. Every such read is recorded in an audit log, and the app tells every user this is possible. Your searches, AI conversations and channel credentials stay out of reach.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Getting an account.&lt;/strong&gt; Anyone can register; nobody gets in until an admin approves them. Until then you see a waiting screen and nothing else.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;what-it-deliberately-does-not-do&quot;&gt;What it deliberately does not do&lt;/h2&gt;
&lt;p&gt;These are choices, not gaps, and they are not going to change:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Place orders.&lt;/strong&gt; No order entry, no broker positions, no depth of market, no paper trading.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Connect to a brokerage.&lt;/strong&gt; The portfolio is typed in or imported from a CSV.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Track tax lots.&lt;/strong&gt; Average cost only, and it says so where it matters.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Fire alerts on ticks.&lt;/strong&gt; Once per trading day, after the close.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Calculate anything in your browser.&lt;/strong&gt; Indicators, crossings and signals are all server-side.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Give advice.&lt;/strong&gt; Analyst consensus is opinion data, an indicator crossing is not a prediction, a financial score is a summary of past filings, and an AI&amp;#39;s read is an analysis, not a recommendation.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Features as of version 0.18.0, 18 October 2026. Example figures are illustrations, not real data.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>Tech</category><category>traderbuddy</category><category>trading</category></item><item><title>TraderBuddy in thirty seconds</title><link>https://smkabir.com/posts/traderbuddy-in-thirty-seconds/</link><guid isPermaLink="true">https://smkabir.com/posts/traderbuddy-in-thirty-seconds/</guid><description>A thirty-second animated tour of TraderBuddy: chart, after-close alert, scanner, SEC financials and portfolio. It tracks and alerts. It never trades.</description><pubDate>Mon, 12 Oct 2026 08:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Thirty seconds, seven scenes, on a loop: what TraderBuddy is for, in the order you would meet it. The sequence is drawn live in your browser from a few hundred lines of script, with no video file behind it, so you can pause it, restart it, or jump to any scene.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;(Interactive content: TraderBuddy in thirty seconds: an animated title sequence. View it on the website.)&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;the-seven-scenes&quot;&gt;The seven scenes&lt;/h2&gt;
&lt;ol&gt;&lt;li&gt;&lt;strong&gt;Thesis&lt;/strong&gt; (0:00). It tracks and alerts. It never trades.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;The chart&lt;/strong&gt; (0:03). Candles building, with a server-computed moving average.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;The alert&lt;/strong&gt; (0:09). A price level armed at 230, and delivered only after the US close.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Scanner&lt;/strong&gt; (0:16). About 600 symbols narrowed to 13.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Financials&lt;/strong&gt; (0:21). A filing from the SEC becomes a financial health score of 78, in five parts.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Portfolio&lt;/strong&gt; (0:25). The cost line steps up when money is added, so a deposit is never mistaken for a return.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Lock-up&lt;/strong&gt; (0:28). The name and the one-line claim again.&lt;/li&gt;&lt;/ol&gt;
&lt;blockquote&gt;&lt;p&gt;TraderBuddy checks alerts once a day, after the close, so the film never shows one firing mid-session. It also never shows an order ticket: there is no order entry anywhere in the product.&lt;/p&gt;&lt;/blockquote&gt;
&lt;h2 id=&quot;keep-reading&quot;&gt;Keep reading&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;&lt;a href=&quot;https://smkabir.com/posts/traderbuddy-features/&quot;&gt;Everything TraderBuddy does&lt;/a&gt;: the feature tour, with the illustrations and example messages.&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;https://smkabir.com/posts/traderbuddy-architecture/&quot;&gt;Architecture and tech stack&lt;/a&gt;: what it is built from, and the provider limits that shaped it.&lt;/li&gt;&lt;/ul&gt;</content:encoded><category>Tech</category><category>traderbuddy</category><category>animation</category><category>trading</category></item><item><title>What the homelab costs to run, in yen</title><link>https://smkabir.com/posts/what-the-homelab-costs/</link><guid isPermaLink="true">https://smkabir.com/posts/what-the-homelab-costs/</guid><description>Two N100 mini PCs and a backup box draw about 39 W: ¥1,024 a month at TEPCO&apos;s November 2026 rates. The same capacity costs about ¥30,400 a month on DigitalOcean and ¥56,900 on AWS.</description><pubDate>Sun, 04 Oct 2026 09:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Two Intel N100 mini PCs, a small backup box, a USB disk, a switch and a UPS run around the clock in my flat in Tokyo. They host this blog, its CMS, TraderBuddy, AiBuddy, Argo CD and the monitoring stack. An earlier version of this post used sample figures. This one uses TEPCO&amp;#39;s current tariff, the real hardware and 30 days of load data, and then prices the same capacity on AWS and DigitalOcean.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Short answer: &lt;/strong&gt;about 39 W around the clock, 28.5 kWh a month, &lt;strong&gt;¥1,024 a month&lt;/strong&gt; or about ¥12,300 a year in electricity. Renting the same four VMs costs about ¥30,400 a month on DigitalOcean and ¥56,900 a month on AWS in Tokyo.&lt;/p&gt;&lt;/blockquote&gt;
&lt;h2 id=&quot;the-hardware&quot;&gt;The hardware&lt;/h2&gt;
&lt;table&gt;&lt;tr&gt;&lt;th&gt;Device&lt;/th&gt;&lt;th&gt;Role&lt;/th&gt;&lt;th&gt;Est. watts&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;TRIGKEY Key-N (pve1): N100, 4 cores, 16 GB&lt;/td&gt;&lt;td&gt;Proxmox: one control-plane VM, the worker VM, two small guests&lt;/td&gt;&lt;td&gt;11&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;TRIGKEY Key-N (pve2): N100, 4 cores, 16 GB&lt;/td&gt;&lt;td&gt;Proxmox: two control-plane VMs&lt;/td&gt;&lt;td&gt;11&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;BMAX B1 Pro-class Celeron box (qdev-pbs)&lt;/td&gt;&lt;td&gt;Quorum tie-breaker, Proxmox Backup Server, etcd snapshots&lt;/td&gt;&lt;td&gt;4&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;USB hard disk&lt;/td&gt;&lt;td&gt;Backup datastore&lt;/td&gt;&lt;td&gt;5&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Gigabit switch&lt;/td&gt;&lt;td&gt;Joins the boxes to the router&lt;/td&gt;&lt;td&gt;3&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;CyberPower SX550U UPS&lt;/td&gt;&lt;td&gt;Its own electronics and charging losses&lt;/td&gt;&lt;td&gt;5&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Total at the wall&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;≈ 39&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;p&gt;The ISP&amp;#39;s router is not on the list. The house would run it anyway.&lt;/p&gt;
&lt;h2 id=&quot;where-the-watts-come-from&quot;&gt;Where the watts come from&lt;/h2&gt;
&lt;p&gt;I have not put a watt checker (&lt;span lang=&quot;ja&quot;&gt;ワットチェッカー&lt;/span&gt; (watto chekkā, plug-in power meter)) on the power strip yet, so each figure starts from a published wall measurement and is adjusted for how busy the box really is. Over the last 30 days Proxmox reports about 13–17% average CPU on pve1 and 16–21% on pve2: light, but never idle, because etcd, Prometheus and the TraderBuddy jobs keep the cores ticking.&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;N100 hosts, 9–14 W each. &lt;/strong&gt;Measured N100 mini PCs idle at about 6–9 W and reach 21–26 W at full load. A Proxmox N100 box running containers measured 11–17 W. I use 11 W.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;qdev-pbs, 3–6 W. &lt;/strong&gt;A B1 Pro-class Celeron box idles at about 3 W headless. It mostly waits for heartbeats and wakes for the nightly backup.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;USB disk, 2–7 W. &lt;/strong&gt;The widest range: about 2 W for a 2.5-inch portable drive and 5–7 W for a spinning 3.5-inch drive in a powered enclosure.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;UPS, 3–8 W. &lt;/strong&gt;CyberPower does not publish a no-load figure for the SX550U, so 5 W is an assumption.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;what-tepco-charges-per-kwh&quot;&gt;What TEPCO charges per kWh&lt;/h2&gt;
&lt;p&gt;The house is on TEPCO&amp;#39;s regulated plan, &lt;span lang=&quot;ja&quot;&gt;従量電灯B&lt;/span&gt; (jūryō dentō B, metered lighting B), whose rates changed on 1 November 2026. The lab only adds kWh on top of the household&amp;#39;s use, so the price that matters is the marginal one. A typical household (TEPCO&amp;#39;s model is 260 kWh a month) is in the second tier, so the lab&amp;#39;s kWh are billed there.&lt;/p&gt;
&lt;table&gt;&lt;tr&gt;&lt;th&gt;Line on the bill (per kWh, tax included)&lt;/th&gt;&lt;th&gt;Nov 2026&lt;/th&gt;&lt;th&gt;Dec 2026&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Energy charge, 120–300 kWh (&lt;span lang=&quot;ja&quot;&gt;電力量料金&lt;/span&gt; (denryokuryō ryōkin, energy charge))&lt;/td&gt;&lt;td&gt;¥36.45&lt;/td&gt;&lt;td&gt;¥36.45&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Fuel cost adjustment (&lt;span lang=&quot;ja&quot;&gt;燃料費調整額&lt;/span&gt; (nenryōhi chōseigaku, fuel cost adjustment))&lt;/td&gt;&lt;td&gt;−¥5.33&lt;/td&gt;&lt;td&gt;−¥4.65&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Renewable energy surcharge (&lt;span lang=&quot;ja&quot;&gt;再エネ賦課金&lt;/span&gt; (saiene fukakin, renewable energy surcharge), FY2026)&lt;/td&gt;&lt;td&gt;¥4.18&lt;/td&gt;&lt;td&gt;¥4.18&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Marginal price&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;¥35.30&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;¥35.98&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;p&gt;I use December&amp;#39;s ¥35.98, the latest confirmed figure. The government&amp;#39;s &lt;span lang=&quot;ja&quot;&gt;電気・ガス料金支援&lt;/span&gt; (denki gasu ryōkin shien, electricity and gas bill support) discount covered October only, so none is included. The base charge (¥1,176.18 for 30 A) does not count: 39 W will never push the house into a bigger ampere contract.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Rule of thumb: &lt;/strong&gt;at ¥35.98 per kWh, every watt that runs 24/7 costs about ¥26 a month, or ¥315 a year.&lt;/p&gt;&lt;/blockquote&gt;
&lt;h2 id=&quot;the-electricity-bill-three-ways&quot;&gt;The electricity bill, three ways&lt;/h2&gt;
&lt;table&gt;&lt;tr&gt;&lt;th&gt;Scenario&lt;/th&gt;&lt;th&gt;Draw&lt;/th&gt;&lt;th&gt;kWh / month&lt;/th&gt;&lt;th&gt;¥ / kWh&lt;/th&gt;&lt;th&gt;¥ / month&lt;/th&gt;&lt;th&gt;¥ / year&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Low: near idle, 2.5-inch disk&lt;/td&gt;&lt;td&gt;28 W&lt;/td&gt;&lt;td&gt;20.4&lt;/td&gt;&lt;td&gt;35.98&lt;/td&gt;&lt;td&gt;735&lt;/td&gt;&lt;td&gt;8,830&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Central estimate&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;39 W&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;28.5&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;35.98&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;1,024&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;12,290&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;High: busier hosts, 3.5-inch disk, house in the third tier&lt;/td&gt;&lt;td&gt;54 W&lt;/td&gt;&lt;td&gt;39.4&lt;/td&gt;&lt;td&gt;40.07&lt;/td&gt;&lt;td&gt;1,580&lt;/td&gt;&lt;td&gt;18,950&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;p&gt;Months are 730 hours. Above 300 kWh a month the tier price is ¥40.54 − ¥4.65 + ¥4.18 = ¥40.07, so in a cold winter month the same 39 W costs about ¥1,140 instead of ¥1,024.&lt;/p&gt;
&lt;h2 id=&quot;the-same-capacity-on-aws-or-digitalocean&quot;&gt;The same capacity on AWS or DigitalOcean&lt;/h2&gt;
&lt;p&gt;The cluster is four Talos VMs: three control-plane nodes with 2 vCPU and 5 GB each, and a worker with 2 vCPU and 6 GB. That is 8 vCPU, 21 GB of RAM and 280 GB of disk. No cloud plan sells 5 GB, so every option below rounds up to 8 GB per node. I priced two ways to rent it: four plain VMs running the cluster exactly as I do now, and a managed Kubernetes service that replaces the three control-plane VMs. Prices are on-demand, in US dollars, converted at ¥158.29 to the dollar (ECB, 3 November 2026).&lt;/p&gt;
&lt;table&gt;&lt;tr&gt;&lt;th&gt;Option&lt;/th&gt;&lt;th&gt;What you rent&lt;/th&gt;&lt;th&gt;$ / month&lt;/th&gt;&lt;th&gt;¥ / month&lt;/th&gt;&lt;th&gt;¥ / year&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Homelab (electricity)&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Hardware already owned&lt;/td&gt;&lt;td&gt;6.47&lt;/td&gt;&lt;td&gt;&lt;strong&gt;1,024&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;12,290&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;DigitalOcean Droplets&lt;/td&gt;&lt;td&gt;4 × Basic 4 vCPU / 8 GB ($48, 160 GB SSD and transfer included)&lt;/td&gt;&lt;td&gt;192.00&lt;/td&gt;&lt;td&gt;30,392&lt;/td&gt;&lt;td&gt;364,704&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;DigitalOcean Kubernetes&lt;/td&gt;&lt;td&gt;3 × the same nodes, HA control plane ($40), 100 GB volume&lt;/td&gt;&lt;td&gt;194.00&lt;/td&gt;&lt;td&gt;30,708&lt;/td&gt;&lt;td&gt;368,496&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;AWS EKS, Tokyo&lt;/td&gt;&lt;td&gt;EKS cluster ($73), 3 × t3.large, 220 GB gp3, 3 public IPv4&lt;/td&gt;&lt;td&gt;343.34&lt;/td&gt;&lt;td&gt;54,348&lt;/td&gt;&lt;td&gt;652,176&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;AWS EC2, Tokyo&lt;/td&gt;&lt;td&gt;4 × t3.large (2 vCPU / 8 GB, $0.1088/h), 280 GB gp3, 4 public IPv4&lt;/td&gt;&lt;td&gt;359.18&lt;/td&gt;&lt;td&gt;56,854&lt;/td&gt;&lt;td&gt;682,248&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;p&gt;The AWS lines break down like this: a t3.large in Tokyo is $0.1088 an hour, or $79.42 a month. gp3 storage is about $0.096 per GB-month in Tokyo. Each public IPv4 address is $0.005 an hour, about $3.65 a month. An EKS cluster is $0.10 an hour on standard support. DigitalOcean includes disk and transfer in the droplet price and does not charge for IPv4. Its Kubernetes control plane is free, and $40 a month makes it highly available, which is what my three etcd members give me.&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;DigitalOcean has no Japan region. &lt;/strong&gt;The nearest is Singapore, so every request from Tokyo would make a longer round trip. AWS Tokyo is next door.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;A t3.large is burstable. &lt;/strong&gt;Its baseline is 30% of each vCPU. My nodes average under 20%, so they would fit, but the nightly TraderBuddy backfills would spend CPU credits.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Cheaper AWS options exist. &lt;/strong&gt;A Graviton t4g.large is $0.0864 an hour in Tokyo, but my images are built for amd64. Savings Plans and Reserved Instances lower the EC2 line if you commit for one or three years. I priced on-demand because the homelab has no commitment either.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Backups and egress are not in the cloud figures. &lt;/strong&gt;Adding the nightly backups and the outbound traffic would only raise them.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;Against the cheapest cloud option, the homelab saves about ¥29,400 a month. If the hardware cost ¥150,000 in total, it paid for itself in about five months. Even with the mini PCs replaced every four years, the electricity-plus-hardware total stays a small fraction of the cloud bill.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The cloud price buys things the homelab does not have: a datacentre with redundant power and network, a provider SLA and hardware nobody has to repair. My whole cluster shares one house, one ISP, one switch and one UPS. For a personal lab that trade is fine. For a business it usually is not.&lt;/p&gt;&lt;/blockquote&gt;
&lt;h2 id=&quot;what-this-number-leaves-out&quot;&gt;What this number leaves out&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;The hardware. &lt;/strong&gt;Spread over four or five years, depreciation is probably larger than the electricity.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;The fibre line and router. &lt;/strong&gt;The house pays for these anyway.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Domain and Cloudflare. &lt;/strong&gt;The tunnel and the R2 buckets fit the free tiers. The domain is a few thousand yen a year.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;UPS batteries. &lt;/strong&gt;A real cost every few years.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Summer cooling. &lt;/strong&gt;About 39 W of extra heat adds a little to the air conditioner&amp;#39;s work.&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;turning-the-estimate-into-a-measurement&quot;&gt;Turning the estimate into a measurement&lt;/h2&gt;
&lt;p&gt;The weakest link is the watts. A plug-in watt checker between the wall and the UPS would replace six estimates with one measured number. When I have it, I&amp;#39;ll update this post. Until then, ¥1,000 a month is the central figure, and the real one is very unlikely to be above ¥1,600.&lt;/p&gt;
&lt;h2 id=&quot;sources&quot;&gt;Sources&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;&lt;a href=&quot;https://www.tepco.co.jp/ep/private/plan/chargelist01.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;TEPCO: 従量電灯B unit prices before and after 1 November 2026&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;https://www.tepco.co.jp/ep/notice/pressrelease/2026/pdf/261029j0201.pdf&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;TEPCO: fuel cost adjustment for December 2026 (press release, 29 October 2026)&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;https://www.tepco.co.jp/ep/private/fuelcost2/backnumber/2611.html&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;TEPCO: fuel cost adjustment for November 2026&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;https://selectra.jp/energy/news/renewable-energy-surcharge-2026-update&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Selectra: FY2026 renewable energy surcharge, ¥4.18 per kWh&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;https://idlewatt.vercel.app/models/trigkey-green-g4&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;idlewatt: Trigkey Green G4 (N100) wall power&lt;/a&gt; and &lt;a href=&quot;https://idlewatt.vercel.app/models/cwwk-n100-4x-i226&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;CWWK N100 under Proxmox&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;https://doc.astlinux-project.org/userdoc:board_bmax_b1_plus&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;AstLinux: BMAX B1 Plus/Pro power notes&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;https://aws.amazon.com/ec2/pricing/on-demand/&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;AWS: EC2 on-demand pricing (Tokyo)&lt;/a&gt;, &lt;a href=&quot;https://aws.amazon.com/ebs/pricing/&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;EBS pricing&lt;/a&gt;, &lt;a href=&quot;https://aws.amazon.com/eks/pricing/&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;EKS pricing&lt;/a&gt;, &lt;a href=&quot;https://aws.amazon.com/vpc/pricing/&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;VPC pricing (public IPv4)&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;https://www.digitalocean.com/pricing/droplets&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;DigitalOcean: Droplet pricing&lt;/a&gt; and &lt;a href=&quot;https://www.digitalocean.com/pricing/kubernetes&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Kubernetes pricing&lt;/a&gt;&lt;/li&gt;&lt;li&gt;&lt;a href=&quot;https://api.frankfurter.dev/v1/latest?from=USD&amp;amp;to=JPY&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Frankfurter (ECB reference rates): USD/JPY on 3 November 2026&lt;/a&gt;&lt;/li&gt;&lt;/ul&gt;</content:encoded><category>Finance</category><category>budgeting</category><category>homelab</category></item><item><title>How this site is built: a static blog on a homelab</title><link>https://smkabir.com/posts/how-this-site-is-built/</link><guid isPermaLink="true">https://smkabir.com/posts/how-this-site-is-built/</guid><description>A headless CMS, a static Astro build, and nginx pods that keep serving even when the CMS is down. The reasoning and the trade-offs.</description><pubDate>Mon, 28 Sep 2026 09:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This site is a &lt;strong&gt;static&lt;/strong&gt; Astro build. The words live in a headless CMS, but readers never touch it: they get plain files from &lt;code&gt;nginx&lt;/code&gt; (or from a mirror in object storage when the homelab is down).&lt;/p&gt;
&lt;h2 id=&quot;the-shape-of-it&quot;&gt;The shape of it&lt;/h2&gt;
&lt;p&gt;Publishing in the CMS triggers a build. The build produces a &lt;code&gt;dist/&lt;/code&gt; folder, uploads it as a numbered release, and moves a pointer. The pods notice the new pointer and swap their content without a rollout.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;(Diagram: view it on the website.)&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;How releases are laid out:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;Static output, one folder per release&lt;ol&gt;&lt;li&gt;Pointer file names the live release&lt;/li&gt;&lt;li&gt;Rollback rewrites the pointer&lt;/li&gt;&lt;/ol&gt;&lt;/li&gt;&lt;li&gt;Content-sync sidecar pulls the new release&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;Where the site build stands:&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;[x] Type check clean&lt;/li&gt;&lt;li&gt;[x] Pagination tried with a small page size&lt;/li&gt;&lt;li&gt;[ ] First real post written&lt;/li&gt;&lt;/ul&gt;
&lt;h2 id=&quot;why-not-just-render-pages-from-the-cms&quot;&gt;Why not just render pages from the CMS?&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Availability: &lt;/strong&gt;a static file keeps working when the database does not.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Cost: &lt;/strong&gt;no application runtime on the read path.&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Security: &lt;/strong&gt;the CMS is only reachable behind an access gate.&lt;/li&gt;&lt;/ul&gt;
&lt;blockquote&gt;&lt;p&gt;A static site on a single-house homelab is strictly less available than a hosted static platform. The point of running it here is to learn, which is why a failover mirror exists.&lt;/p&gt;&lt;/blockquote&gt;
&lt;h2 id=&quot;typed-content-end-to-end&quot;&gt;Typed content, end to end&lt;/h2&gt;
&lt;p&gt;The schema is code. Types generated from it are imported by the site, so a schema change that breaks a page fails the type check before it fails a reader.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import type { Post } from &amp;#39;~/lib/types&amp;#39;;

export function hasPage(post: Post, locale: Locale): boolean {
  return post._status === &amp;#39;published&amp;#39; &amp;amp;&amp;amp; post.publishedIn[locale] === true;
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;building-it&quot;&gt;Building it&lt;/h2&gt;
&lt;p&gt;A local build is two commands:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ pnpm install --frozen-lockfile
pnpm build
i18n: 96 keys present in all locales
[build] 24 page(s) built in 3.1s
[build] Complete!&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;Published URLs are permanent. Changing a slug without a redirect is a bug.&lt;/blockquote&gt;
&lt;p&gt;More in the next part, where the CMS goes into the cluster. See the &lt;a href=&quot;https://smkabir.com/projects/homelab/&quot;&gt;homelab write-up&lt;/a&gt; for the platform.&lt;/p&gt;
&lt;h2 id=&quot;an-interactive-demo&quot;&gt;An interactive demo&lt;/h2&gt;
&lt;p&gt;Posts can include small interactive pieces written as plain HTML and JavaScript. They run in a sandboxed frame, so they cannot touch the site around them or send anything anywhere.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;(Interactive content: A line that grows with each button press. View it on the website.)&lt;/em&gt;&lt;/p&gt;</content:encoded><category>Tech</category><category>homelab</category><category>kubernetes</category><category>astro</category></item><item><title>The Japanese words that paperwork actually uses</title><link>https://smkabir.com/posts/japanese-paperwork-vocabulary/</link><guid isPermaLink="true">https://smkabir.com/posts/japanese-paperwork-vocabulary/</guid><description>A short glossary of terms that keep turning up on forms and at counters, with the reading next to each one.</description><pubDate>Tue, 15 Sep 2026 09:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Everyday life in Japan runs on forms. A handful of words cover most of them, such as &lt;span lang=&quot;ja&quot;&gt;本免許&lt;/span&gt; (honmenkyo, full driving licence) and &lt;span lang=&quot;ja&quot;&gt;表札&lt;/span&gt; (hyōsatsu, nameplate). Hover or tap a term to see the English.&lt;/p&gt;
&lt;figure&gt;&lt;img src=&quot;https://smkabir.com/fixtures/sample-landscape.svg&quot; alt=&quot;A placeholder landscape illustration&quot; width=&quot;1600&quot; height=&quot;1000&quot;&gt;&lt;figcaption&gt;A placeholder figure with a caption.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;Cars have their own vocabulary too. The plate on the back is the &lt;span lang=&quot;ja&quot;&gt;ナンバープレート&lt;/span&gt; (nanbā purēto, number plate), and it is one of the first things to hide in a photo.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://smkabir.com/fixtures/sample-landscape.svg&quot; alt=&quot;A placeholder landscape illustration&quot; width=&quot;1600&quot; height=&quot;1000&quot;&gt; &lt;img src=&quot;https://smkabir.com/fixtures/sample-portrait.svg&quot; alt=&quot;A placeholder portrait illustration&quot; width=&quot;1000&quot; height=&quot;1400&quot;&gt; &lt;img src=&quot;https://smkabir.com/fixtures/sample-landscape.svg&quot; alt=&quot;A placeholder landscape illustration&quot; width=&quot;1600&quot; height=&quot;1000&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Before publishing any photo, check the plate, the nameplate, street signs and reflections. See the checklist in the editor.&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;If you have a favourite word that tripped you up, &lt;a href=&quot;https://smkabir.com/contact/&quot;&gt;tell me&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>Japan life</category><category>language</category><category>paperwork</category></item></channel></rss>