- Fees, not the ML model, decided whether the bot made money.
- At ~10 trades/day, round-trip fees ate an estimated 20-30% of the account per month.
- Running ML on a 1GB server needs per-cycle GC and a hard memory valve.
I spent a few weekends building a machine-learning crypto trading bot, ran it live on real money for a few weeks, and lost money. Then I shelved it.
This is the honest post-mortem. And the punchline is uncomfortable: the machine learning was the least important part of the whole thing. The two lessons that actually decided whether I made or lost money had nothing to do with XGBoost. They were about transaction fees and a server with 1GB of RAM.
If you’re about to build your own trading bot, I’d rather you learn these from my P&L than your own.
What I built
The setup was reasonable on paper:
- Per-coin ML models. For each coin, the bot trained both an XGBoost and a RandomForest classifier and kept whichever scored better. The label was a simple “will the price be higher N candles from now.”
- A trend filter. It only bought when the short-term trend was up — current price above the 20-period moving average, and
MA5 > MA20. The idea: don’t fight the tape. - Asymmetric confidence thresholds. Buy when the model was ≥55% confident, sell only at ≥65%. I wanted it slower to panic-sell than to buy.
- Multi-coin. Up to 5 positions at once, picked by a scanner that scored the top coins by trading volume.
- Retraining every 2 hours so the models could “adapt.”
- It ran as a
systemdservice on an Oracle Cloud free-tier box (ARM, 1GB RAM), with a Telegram bot for/start,/stop,/status.
I funded it with about 50,000 KRW (~$40) — 10,000 KRW per coin. Small enough that losing all of it would only hurt my pride.
It hurt my pride.
The first reality check
Here’s an actual portfolio snapshot from the logs, a few hours into a live run:
KRW: 7,949
Positions (5):
KRW-BCH @ 878,500 -> 877,000 (-0.17%)
KRW-EGLD @ 9,775 -> 9,780 (+0.05%)
KRW-BEAM @ 5 -> 5 (-0.20%)
KRW-ZRO @ 2,559 -> 2,559 (+0.00%)
KRW-KERNEL @ 113 -> 113 (+0.00%)
Total Value: 57,917 KRW
Look at those move sizes: −0.17%, +0.05%, −0.20%, 0.00%, 0.00%.
Now hold that thought, because here’s the number that quietly killed the bot: Upbit charges 0.05% per trade. A round trip — buy then sell — costs 0.1%.
My positions were oscillating inside a band smaller than my fees. That’s not a strategy problem you fix with a better model. That’s arithmetic.
Lesson 1: Fees quietly ate everything
In its first incarnation, the bot was eager. It checked signals every 3 minutes, bought at 55% confidence, sold at 65%, and — this is the embarrassing part — when a position hit a stop-loss but the model still said “up,” it would buy more to average down.
The result: roughly 10 trades a day. At 0.1% round-trip each, churn alone was burning an estimated 20–30% of the account per month before the market even had an opinion. I was running a very sophisticated machine for converting my balance into exchange fees.
The fix wasn’t smarter ML. It was trading less:
# Minimum holding period — stop churning on fees
self.min_holding_minutes = 120 # hold at least 2 hours
self.min_buy_confidence = 0.60 # was 0.55
self.min_sell_confidence = 0.70 # was 0.65
# ... inside the sell check ...
holding_minutes = (now - entry_time).total_seconds() / 60
if holding_minutes < self.min_holding_minutes:
continue # too soon — hold
# Don't sell into the fee wall
FEE_PCT = 0.001 # 0.1% round trip
pnl_pct = pos['current_price'] / pos['avg_price'] - 1
if 0 < pnl_pct < FEE_PCT:
continue # "profit" is smaller than fees — hold
And I deleted the averaging-down logic entirely. Adding to a losing position because a 55%-confident model is optimistic is how small losses become big ones.
I also slowed the loop from a 3-minute to a 5-minute cycle. Combined, daily trades dropped from ~10 to ~2–3, and the estimated fee drag fell from 20–30% to roughly 6–9% per month.
That is a massive improvement. It’s also still 6–9% per month you have to out-earn just to break even. On $40 of micro-cap crypto, that’s a brutal hurdle.
Takeaway: model your fees before your strategy. For a high-frequency-ish bot on retail crypto, fees aren’t a footnote — they’re the main character.
Lesson 2: Running ML on a 1GB server is its own sport
I wanted this thing running 24/7, so it lived on a free-tier cloud box with 1GB of RAM. Per-coin models, retraining every 2 hours, pandas dataframes of price history — it added up fast, and the process would balloon until Linux’s OOM killer reaped it. A trading bot that silently dies at 3 a.m. is worse than no bot.
The original memory handling was lazy: garbage-collect every 10th cycle and hope. On 1GB, hope is not a memory strategy. I added an explicit cleanup that runs every cycle:
def cleanup_memory(self):
now = datetime.now()
# Drop the whole model cache once an hour
hours_since_clear = (now - self.last_cache_clear_time).total_seconds() / 3600
if hours_since_clear >= self.cache_clear_interval_hours:
self.model_cache.clear()
self.last_cache_clear_time = now
gc.collect() # every cycle, not every 10th
Plus an emergency valve and a lower threshold:
check_memory_and_gc(threshold_mb=300) # was 400
if mem_info['percent'] > 85: # system memory critical
trader.model_cache.clear()
gc.collect()
print("[MEM] Emergency cleanup triggered!")
I also ran the live service with --no-lstm to drop the heaviest model entirely. After that, the process stayed comfortably under budget and stopped getting killed.
Takeaway: if you train ML on a small box, treat memory as a first-class feature. Cache eviction, per-cycle GC, and a hard emergency valve are not premature optimization — they’re the difference between “running” and “dead by morning.”
The uncomfortable truth about the ML
So did the models work? Sort of. Not enough.
Looking back at the signal logs, model confidence clustered in the 45–62% range — BTC buy at 62.5%, ETH at 60.2%, a lot of sells in the high-40s. For a binary up/down call, that is barely better than a coin flip, and “barely better than a coin flip” does not survive a 0.1% fee on every round trip.
Retraining every 2 hours sounded adaptive but mostly meant the models were perpetually chasing the last two hours of noise. The trend filter (MA5 > MA20) was probably the single most useful component, and it’s not ML at all — it’s a moving-average crossover from the 1970s.
The deeper issue is structural. With ~$40 spread across five micro-cap coins, I was playing in exactly the market segment where edges are thinnest, spreads are widest, and fees bite hardest. No amount of gradient boosting fixes “the math is against you at this size.”
Would I do it again?
The bot, as a trading product: no. Not at that capital size, not with per-coin classifiers chasing 2-hour noise on fee-heavy micro-caps.
The bot as a learning project: absolutely, and I’d do it the same messy way. I now understand exchange fees, OOM behavior on tiny servers, the danger of averaging down, and why “more frequent trading” is usually a tax you pay to feel busy. Those lessons transfer everywhere.
If I rebuilt it, I’d start from the boring end: a brutally honest backtest with fees and slippage baked in, on real capital sizing, before a single line of live-trading code. If the strategy can’t clear fees in a backtest, it has zero chance live. Mine couldn’t, and I found out the expensive way.
For the theory I skipped and probably shouldn’t have, Advances in Financial Machine Learning on Amazon is the book people kept telling me to read before building. They were right.
FAQ
Q: Did the ML actually predict prices?
A: Weakly. Confidence sat in the 45–62% range — better than random, but not by enough to overcome 0.1% round-trip fees. The non-ML trend filter (MA5 > MA20) was arguably more useful than the models.
Q: What was the single biggest mistake?
A: Trading too often. At ~10 trades/day, fees alone were eating an estimated 20–30% of the account per month. Cutting frequency to 2–3 trades/day mattered more than any model change.
Q: Can you make money with a retail crypto trading bot?
A: Maybe, but not the way I did it — tiny capital, micro-cap coins, and frequent trading is the worst possible combination because fees dominate. Bigger edges, lower turnover, and fee-aware backtesting are the bare minimum.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,932 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (988 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (975 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (874 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (683 views)