The Lie Called "5 Approved, 0 Rejected"
A post-mortem of the first unattended trading day, when all five orders were rejected over a single decimal point and the alert reported success.
The trading bot runs as a resident launchd daemon on a Mac mini. The rule engine generates buy signals, submits orders to the broker’s paper account, and reports the results over Telegram. On the first morning it placed orders without a human hand, a message came in saying it had succeeded, with not one line about anything filling.
The first morning of unattended operation
Yesterday I put the trading bot up as a resident launchd daemon. The moment I registered it, an alert arrived saying “unattended operation has started,” which felt surreal. Today is Monday, the first day the system places orders without a human hand. The flow is simple. Send a morning briefing before the open, at 9:00 the rule engine generates buy signals and submits orders to the broker’s paper account, and the results get reported over Telegram.
At 9:06 the trade-result message came in. “5 approved, 0 rejected.” Success, I thought, but something was off. Nothing anywhere said anything had filled.
First I had it open the logs
I asked Claude Code straight out.
Did it buy? I don’t think the result message was sent. The morning briefing came really late too.
Claude started with the daemon logs and DB records. The briefing and the result message had been sent on time; only the phone notification showed up late. The real problem was elsewhere. A few minutes later the report came up: all 5 buy orders had been rejected at the broker submission stage.
Total rejection, and a false report
The incident had two layers.
The first was the rejection itself. Order prices were being sent with a decimal point attached, like 120800.0. The broker API only accepts prices as integers. The quote-lookup side had been verified in advance, but actual order submission was happening for the first time today, and it got caught on the very first ride down that path.
The second was scarier. The alert reported “5 approved, 0 rejected.” The alert logic only counted rejections from the internal safeguards and didn’t count broker-side rejections at all. From the system’s point of view, “I blocked nothing” is a true statement, but the person receiving it reads it as “all buys went through.” In an unattended system, worse than a failed order is an alert that reports the failure as success.
The fix, the retry, and a crash of all days
The fix itself was quick. Prices are now always sent as integers, with a regression test attached (all 88 tests pass). The alert was changed to count broker rejections too. Restart at 09:51, manual retry at 09:54. This time 4 tickers filled immediately and only the treasury bond ETF waited briefly before filling. Final result, all 5 fully filled: domestic stock-index ETF 10 shares, US S&P 500 22 shares, Nasdaq 100 6 shares, treasury bonds 15 shares, physical gold 36 shares. I added per-fill alerts while I was at it. At design time I’d bundled them under a “one summary” principle, but with only a handful of trades a month there was no reason per-fill alerts would be noisy.
But of all days, this was the day the domestic stock-index ETF crashed -9.77%. Circuit-breaker territory. Watching the index plunge nearly -10% on my first buy day, I wanted to sell everything, rules be damned.
KOSPI is far more volatile this year. There’s a circuit breaker once a week.
I threw the anxiety at it as-is and had it verify with data. The result was unexpected. The index was -9.77%, but the portfolio as a whole was -2.76%. Domestic equities are only 25% of the weight, so even when the index plunges, the portfolio stops around -3%. In a backtest including every circuit-breaker-level down day this year, the portfolio’s worst day was -3.8%, and the daily loss limit (-6%) was triggered on 0 days. While at it, I had five countermeasures, such as an automatic sell-on-crash rule, backtested as variants over 10 years of data from 2016, and every one was worse than the current setup. Selling the day after a circuit breaker meant missing the rebound, that sort of thing. Claude’s conclusion was “the current setup wins on the data.” The data held back the hand that wanted to sell.
Lessons
- Verifying quote lookup does not verify order submission. A live API needs to be actually exercised at least once per path. We got caught on exactly the first real-order path.
- A false alert is more dangerous than a failed order. In an unattended system the only thing a human sees is the alert, and if the alert calls a failure a success, monitoring itself is neutralized.
- “0 rejected” was a phrase that needed the follow-up “who counted the rejections?” The internal safeguards and the broker’s rejections were different counters.
- Panic on a crash day was better suppressed with data. Index -9.77%, portfolio -2.76%. Only after seeing in numbers that diversification is itself a defense did my hand stop.