My Trading Terminal Finally Works When It's Closed

Posted by Michael S. on June 5, 2026

For a while now I've been building my own trading terminal. A desktop app on top of Tauri, a small Rust process I call the sidecar that talks to the brokers and streams quotes, charts that try to feel like TradingView without being TradingView. The charts were the fun part.

The recent stretch of work was not the fun part. It was the half nobody blogs about: making the thing keep doing its job after I close the laptop. That turned out to be most of the work, and it's where all the interesting failures lived.


The actual feature: alerts that fire when nothing's open

Here's the use case. I want to know when a stock drops to its 20-day or 50-day moving average. Or for some names, when it crosses below its 14-week average plus a couple dollars. Different rule per symbol. And I want to find out on my phone, at 2am, with the app nowhere in sight.

The app already had moving-average alerts. They just didn't do what I needed in two ways. First, "price below the SMA" meant exactly the SMA, with no room to say "within ten dollars of it." Second, the average was always computed on the chart's own bars, so there was no way to ask for a weekly SMA. So I added an offset and a daily/weekly toggle to the rule. Now a rule reads like a sentence: price below the 20-day SMA plus ten.

The harder part is that a browser tab can't page your phone when it's closed. The app stores its alerts in local storage, which a headless process can't read. So the rules now get mirrored out: the app posts them to the sidecar, the sidecar writes them to a file, and a small daemon reads that file every minute, pulls fresh prices, and checks each rule. When one trips, it sends a Telegram message. The daemon runs as a system service, restarts itself if it dies, and survives reboots. No app required.

One detail I'm a little proud of and a little annoyed by: the daemon prices every symbol in a single batched request to the broker, not one per symbol. Schwab lets you ask for up to 500 tickers in one call. So whether I'm watching one name or thirty, it's about one request a minute. That matters more than it sounds like when there's a rate limit hanging over you.

There's a wrinkle I'm still cleaning up. When the app is open, it also evaluates the same rules in the browser. Same definitions, two engines, slightly different firing behavior. They only coordinate through one flag, which leads to the odd surprise where the desktop app firing an alert quietly tells the daemon to stop watching it. I've got a plan to make the daemon the only thing that fires these, with the app just showing them. Not done yet. But the 24/7 path works, which was the point.


Two processes walked into an OAuth token

Now the part that ate a day.

Schwab's API hands you a refresh token good for seven days. Every so often you trade it for a fresh access token. Standard stuff. Except Schwab rotates the refresh token each time you use it: you hand in the old one, you get a new one back, the old one dies.

I had two processes doing this against the same token file. The sidecar, serving the desktop app. And the new alert daemon, which I'd given its own broker adapter. Each one, independently, would notice the access token had expired and go refresh it. Each refresh minted a new token and killed the previous one. They were quietly mugging each other.

The symptom was maddening because it kept looking fine. The token file said the refresh token was fresh, issued minutes ago, valid for a week. Schwab said it was revoked. Both were telling the truth. The file showed the last token one of them had grabbed; Schwab had already invalidated it because the other one grabbed a newer one a second later. So the whole thing would die every couple of days with no obvious cause, and I'd re-auth, and it'd die again.

The fix is boring, which is the right kind of fix: one owner. The daemon no longer holds a Schwab token at all. It asks the sidecar over local HTTP for prices and history, and the sidecar is the only thing that ever refreshes. No race, no rotation fight. The token now lasts its full seven days like it's supposed to.


The re-auth that took three runs and two text messages

There's a catch I glossed over. Schwab's refresh token dies every seven days, hard. Trading it in for a fresh access token doesn't reset the clock; the seven days run from the moment you log in through the browser, and when they're up you log in through the browser again or the whole thing goes dark. So "keeps working when I'm gone" has an asterisk. Something has to drive a real browser through Schwab's login and consent screens on a schedule, with nobody watching.

I'd built a headless browser to do that. Getting it to actually work, end to end, was its own little comedy.

First run got all the way in. It filled the login, took the SMS code, ticked "Trust this device," and then fell over on the very last click: the "Allow" button on the consent page. The automation had grabbed a hidden, zero-size copy of that button instead of the real one, and the click threw "not clickable." So close it stung.

Second run, with that fixed, walked the whole consent flow cleanly. Continue, Accept, Continue, Done. And it still failed, in a more interesting way. Schwab ends OAuth by redirecting the browser to https://127.0.0.1 with the authorization code sitting right there in the URL. Nothing listens on that address. The browser threw connection-refused, the address bar collapsed to an error page, and the code went with it. The code had been issued. I just wasn't fast enough to read it before the page died.

Third run, I stopped trusting the address bar. Instead of reading the final URL, I listened for the navigation event and grabbed the 127.0.0.1 link the instant the browser tried to load it, a beat before the refusal. That time it went all the way through and refreshed the token. With no text message at all.

That last detail is the one I keep turning over. The second run's "Trust this device" hadn't written anything useful on my end. It had registered the device with Schwab, on their side. I only found out because the third run started from an empty cookie jar and Schwab waved it past 2FA anyway. The trust wasn't in a file I owned. It was in their memory of the machine.

Two texts, three runs, one clean pass, and a genuinely useful thing learned: once the device is trusted, the whole dance runs silent.

So now it renews itself. A timer checks once a day and only does the full browser re-auth when the token is inside two days of expiring, which works out to about once a week. And the scheduled job is built so it can never text me out of nowhere: if that device trust ever lapses, it stops before triggering a code and pings my phone to do the re-auth by hand instead. The line I closed the first draft of this post on, "still need to make the re-auth hands-off," is crossed out now.


The error that hid for an hour

Smaller story, sharper lesson. While chasing the token thing, every failure logged as this:

refresh failed HTTP 400: \x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03 ...  (the gzip stream, logged as raw bytes)

Garbage. Total noise. I burned real time staring at it.

That 1f 8b at the front is the gzip magic number. Schwab's edge was gzip-compressing its error responses, and the sidecar was reading the raw compressed bytes as text and logging the deflate stream. The real message was sitting right there, one decompress away. The moment I taught it to inflate the body, the same failure printed "invalid_client" and then later "refresh token is invalid, expired or revoked" in plain English, and the diagnosis went from an hour to a glance.

If your error handler can swallow a 400 into mojibake, it will, at the worst possible time. Decode your error bodies.


When the cloud backend just... vanishes

The app had a feature for pinning posts to charts. Tweets, articles, that kind of thing. It logged each saved post to a Supabase project.

Two problems, found in one sitting. One, the Supabase project was gone. Its hostname returned NXDOMAIN. Free-tier projects pause after a week idle and get deleted after long enough, and this one had lapsed into the void. Every save had been failing silently into a retry queue for who knows how long, because the whole write path was fire-and-forget with the errors swallowed. Two, and this is the better one: there was no read-back code at all. The app only ever wrote to the cloud. It displayed posts from local storage. So even with a live database full of rows, nothing would ever pull them down and show them.

I'd been carrying a "saved + synced" feature that was neither.

I didn't go provision a new database. The sidecar's already running on my tailnet, it already owns the durable state, so I gave it three little endpoints and pointed the posts at it. Now a save writes through to the sidecar, a delete propagates, and on load the app pulls everything back and merges it in. No external service to lapse, nothing to forget to pay for. The lesson I keep relearning: the simplest durable thing you already run beats the cloud service you'll forget exists.


Failures that don't name their cause

When the token finally lapsed for real, nothing in the app said "auth." It said other things, in other languages.

The symbol search went deaf. Type "now," get "no matches for now." The search code was fine. It resolves what you type against Schwab's instrument endpoint, and that endpoint needs a live token, so a dead token reads back as "no such company." The chart blanked the same way. Every symptom pointed somewhere other than the one thing that was actually broken.

The alert rules drifted too, and that one was on me. They'd only ever lived in the browser's local storage, pushed one direction out to the daemon. At some point the browser's copy got reset down to a single stray rule, and because the sync ran one way, the daemon dutifully mirrored the reset. My real rules, the ones watching for a stock to fall back toward its moving average, were just gone, with nothing announcing it.

The fix is the same lesson the dead database taught me, one more time: make the durable thing you already run the source of truth. The daemon's rule set is now a backstop the app reads back on load. Clear the browser and the rules return instead of evaporating.


The pile of smaller stuff

Plenty of it didn't need its own war story:

  • Higher-timeframe moving averages. You can now put a daily 200-SMA on a five-minute chart. The average re-buckets the chart's bars to whatever timeframe you pick, computes there, and maps the value back. Handy for seeing a level you'd otherwise have to flip charts to find.
  • The 8, 9, and 21 EMAs, because those are the ones I actually watch, and they weren't there.
  • Chart polish toward the TradingView look: a faint symbol watermark behind the candles, the matching typeface, and the indicator picker collapsed by default so a fresh chart isn't a wall of checkboxes.
  • A companion mobile web app, stripped down to a watchlist, a chart, and settings, talking to the same sidecar over the network.
  • Bid/ask in the quote endpoint. The broker was already returning level-one book data and the sidecar was throwing it away. Now another local tool can read it.
  • The services run under systemd now instead of as loose background processes, so a crash or a reboot doesn't take the data layer down for good.

What I keep taking away from this

The charts were a weekend. Keeping live broker data flowing, alerts firing to my phone when I'm asleep, and state that survives a dead cloud project. That's the part that's taken real time, and it's the part that makes the thing a tool instead of a toy.

Most of it isn't clever. It's one process owning the token instead of two. It's decoding your error bodies. It's listening for the redirect instead of reading the address bar. It's reaching for the boring durable thing you already run. The unglamorous decisions are the ones that decide whether you trust it at 2am.

It watches the market for me now whether I'm there or not, and it renews its own keys to keep doing it. That's the line I was trying to cross.