Moving my AI trading bot off my desktop and onto GitHub Actions
My trading bot's dashboard said it had been tracked for 40 days when it should've been closer to 110. The bug wasn't in the trading logic, it was in how the bot got scheduled to run at all.
I run a panel of ten AI agents that debate and trade stocks on a paper account, purely to see whether they'd actually beat the market or just confidently lose to it. It's been running since late April. So when I glanced at the dashboard last week and saw Days Tracked: 40, that didn't add up. April to August is more like 110 calendar days, or about 76 weekdays once you take out the ones markets are closed.
What the number actually meant
Finding the gap
The dashboard reads a JSON snapshot the bot pushes to Redis after every run. I pulled the raw history straight out of Redis and just looked at the dates:
2026-04-22
2026-04-23
...
2026-06-04
2026-06-17 <- 13 days missing
2026-06-18
...
2026-07-28
2026-08-05 <- 8 days missing
2026-08-06Not weekends, actual multi-day holes. Whatever was supposed to run the bot every weekday just wasn't, for stretches of a week or more.
The actual bug
The bot was scheduled with Windows Task Scheduler, triggered daily at 3pm BST. I'd set it up with WakeToRun and StartWhenAvailable, so I assumed it was resilient to the PC being asleep. The part I'd missed was the logon type:
$Principal = New-ScheduledTaskPrincipal `
-UserId $env:USERNAME `
-LogonType Interactive `
-RunLevel LimitedLogonType Interactive means the task only runs while an interactive session is logged in on that specific machine. WakeToRun will wake the PC from sleep for it, but if the PC is fully powered off, or I'm just not logged in, the trigger is silently skipped, and nothing tells you. Task Scheduler does keep a record in its own history panel if you go looking, but there's no email, no failure notification, nothing that surfaces on its own. It just doesn't happen. StartWhenAvailable catches up with a single run once the PC's back, but it doesn't backfill every day that was missed in between. That's exactly the shape of the gaps I was seeing.
Why not just fix the logon type
That's a one-line fix (switch to LogonType Password or run as SYSTEM), but it only solves half the problem: it still depends on my PC being physically on. A trading bot that stops recording data whenever I go away for a weekend isn't something I want to keep babysitting.
I looked at three ways to get it off my desktop entirely:
Options considered
| Option | Verdict |
|---|---|
| AWS EC2 free tier | Free for 12 months only, and still a server to patch and remember exists |
| Oracle Cloud Always Free | Actually free forever, but still a real VM to maintain for a job that runs for 6 minutes a day |
| GitHub Actions | No server at all. The repo already exists, the job is small, and free minutes cover it easily |
The bot's whole daily cycle is about six minutes of mostly-waiting-on-API-calls (it staggers requests to stay under OpenAI's rate limits), which made GitHub Actions the obvious fit. There's no infrastructure to run at all when the "infrastructure" is a CI runner that only exists for six minutes a day.
What actually changed
The workflow compiles and runs the bot exactly like the old batch script did, javac then java -cp out Main trade --live, just on a GitHub-hosted Ubuntu runner instead of my PC. A couple of things needed solving that the old script never had to worry about.
GitHub Actions cron is UTC-only, and it has no idea daylight saving exists. The target is 10am ET, 30 minutes after market open, but that's 14:00 UTC during EDT and 15:00 UTC during EST, and the boundary shifts twice a year on dates I'd otherwise have to remember to update by hand. The actual fix is to just schedule both:
schedule:
- cron: '0 14 * * 1-5'
- cron: '0 15 * * 1-5'A guard step then checks the real local time in America/New_York and skips whichever trigger didn't land on 10am ET that day, so exactly one of the two actually runs a trading cycle. It's a slightly silly-looking two-cron workaround for a problem that doesn't exist if you're running on a machine that lives in your own timezone, which is exactly the kind of thing you only discover once you stop doing that.
The other new problem: runners don't persist anything. Every run starts from a clean checkout and gets thrown away afterward, which is fine for compute but not for the bot's own memory, the position history and benchmark tracking it needs from one day to the next. The fix is inelegant but it works: the workflow commits the analytics JSON straight back to the repo at the end of every run. The repo is my database now, which is a mildly unhinged sentence to write about a stock trading bot, but it's free, it's versioned, and it has never once gone down.
Secrets work the same way they always did. Config.java already checked environment variables before falling back to config.properties, so nothing in the bot's code needed to change; the workflow just needed the same variable names as GitHub repository secrets and variables instead. Anything touching an API key or account went in as an actual secret; anything non-sensitive (model names, starting capital, comparison tickers) went in as a plain repository variable.
Where it stands now
The first real deployment had its own bit of comedy: the day I actually went to turn this on, GitHub Actions had a site-wide outage. My first debugging session wasn't the bot's code at all: it was staring at GitHub's status page wondering whether I'd broken something or the entire platform had. (It was the entire platform, that time.)
It's been running properly since, which means I don't have a before-and-after gap chart yet: I have a falsifiable claim instead. The schedule no longer cares whether my laptop is open, asleep, or a thousand miles away. If Days Tracked matches the calendar in 30 days, the fix worked. If it doesn't, at least now I know exactly where to look.
You can watch it live on the trading dashboard, including today's agent debate, current positions, and whichever benchmark index is currently making it look bad.
About the Author

Written by Connor Shields