Yesterday I finished the per-order quantity guard. Today’s task, the second Phase 7 prep item, was a cumulative loss limit that auto-pauses the bot β and most of the time went into design decisions, not code.
π νκ΅μ΄λ‘ 보기
- Phase 0 β Project Foundation
- Phase 0-1 β Currency Unit Bug Fix
- Phase 1 β Config Separation & Retry Logic
- Phase 1-2 β Config/Retry Follow-up
- Phase 1-3 β Introducing pytest
- Phase 1-4 β Log Rotation
- Phase 2-1 β Strategy Class Design
- Phase 2-2 β Multi-Symbol Monitoring
- Phase 3-1 β Data Persistence Design
- Phase 3-2 β DB Module Implementation
- Phase 3-3 β DB Integration
- Phase 2-3 β Strategy Plugin Interface
- Phase 4 β Backtest Engine
- Phase 4 β Candle API Pagination
- Phase 5 β Read-Only Dashboard
- Phase 5 β Telegram Control Logic
- Phase 5 β Telegram Control Logic 2
- Phase 6 β Scheduler & Watchdog
- Phase 7 β Per-Order Quantity Guard
- Goal: Auto-pause new-buy monitoring once account-wide unrealized P&L crosses a configured threshold (default -10%).
- Key decision: With no realized-P&L tracking yet, I used the unrealized P&L field the API already returns (
profitLoss.rate), evaluated cumulatively with no midnight reset. - Verification: 7 new tests in
test_risk_control.py, plus updated existing tests β full suite at 131 passing.
Why Unrealized P&L Instead of Realized P&L
The first thing I had to settle was what “loss” even means here. A proper risk system usually tracks realized P&L from actual sell trades. But this bot’s current architecture is “buy, then monitor” β there’s no table recording sells at all. Building realized-P&L tracking from scratch would’ve turned today’s task into a data-model project instead of a risk-control feature.
So I used what the Toss Securities API already returns: profitLoss.rate, the unrealized P&L percentage per holding. Summing that across the account and checking it against a threshold (default -10%) needed no new data model at all.
No Midnight Reset β Cumulative by Design
Second decision: when does the loss window reset? A daily reset (“down 10% today”) sounded reasonable, but it drags in timezone handling and boundary-condition tests I didn’t want to deal with yet. I went with a simple cumulative check across the whole account instead β no reset logic, fewer edge cases to get wrong.
risk_control.py β check_and_enforce_loss_limit()
The new risk_control.py module’s core function, check_and_enforce_loss_limit(), checks account-wide unrealized P&L and, if it breaches the threshold, calls the existing Phase 5 engine_control.pause() to stop the buy-monitoring loop only.
One design principle I nailed down explicitly: this module never sells anything. Deciding whether to sell existing holdings stays a human decision. Automation’s job here is “stop buying more” β automating the sell decision would be a completely different scope of responsibility.
I also added a state check so it doesn’t send duplicate alerts if the account is already paused and still over the threshold on the next scan.
Integrating into scheduler.py β Avoiding Duplicate API Calls
Where to run the check was its own question. It could’ve gone into the real-time buy-monitoring loop in auto_trader.py, but scheduler.py β which already scans the whole account every 30 minutes β was the better fit for separation of concerns.
That required one refactor. The existing toss_portfolio.collect_price_snapshots() handled the API fetch and the snapshot write in one function. For the scheduler to reuse the same fetch result for both snapshot storage and the loss check, I split out the storage half into a pure function, store_snapshots_from_holdings(), and left collect_price_snapshots() as a thin wrapper calling it. That way a single scan feeds two consumers without a second API call.
I didn’t build new alerting either. When a pause triggers, the pause-detection logic already in auto_trader.py from Phase 5 sends the Telegram notification on its next cycle β reusing existing infrastructure instead of duplicating it.
In short: the bot now auto-pauses new-buy monitoring past a -10% unrealized P&L threshold, cumulative with no reset, while selling stays a manual decision.
Verification β 131 Tests Passing
test_risk_control.py got 7 new tests: within/over threshold, boundary values, alerts-table logging, no duplicate alerts while already paused, and edge cases like missing data or a missing rate field.
test_scheduler.py was updated to confirm fetch_holdings() is called exactly once and the same data is reused for both snapshot storage and the loss check. test_config.py got a regression test confirming the loss-limit value itself stays within a safe range (1%β50%).
Despite the refactor, the existing collect_price_snapshots tests passed unchanged β same signature, same return value. A full pytest run confirmed 131 passing (122 existing + 9 new).
Now a 5-Layer Safety Net
The existing 4-layer safety net (per-order limit, DRY_RUN simulation, 1-won target price, physically commented-out order API) now has a fifth layer: cumulative loss-limit auto-pause.
| Layer | What it does |
|---|---|
| 0 | Reject immediately if per-order quantity exceeds the limit |
| New | Auto-pause monitoring if cumulative unrealized P&L breaches the limit |
| 1 | DRY_RUN=True simulation mode |
| 2 | Target price set to 1 won (unfillable) |
| 3 | Order API call physically commented out |
What’s Left
Remaining Phase 7 prep items: reviewing API key permission scope, redundant alerting (Telegram + email), and backtesting with real target prices and strategy logic. Once those are done, the plan is to roll back the safety layers step by step, only with explicit sign-off. (This post covers the safety-net development process only β no real account or trade data is included.)
FAQ
Q1. Why unrealized P&L instead of realized P&L for the loss check?
The bot’s current “buy then monitor” architecture has no table tracking sells, so there’s no realized P&L to compute yet. I used the unrealized P&L field (profitLoss.rate) the API already returns.
Q2. Does breaching the loss limit auto-sell holdings?
No. It only pauses new-buy monitoring. Selling existing holdings stays a manual decision β that’s intentional, not a gap.
Q3. How does the loss-tracking window reset?
It doesn’t reset at midnight β the check is cumulative across the whole account, to avoid timezone and boundary-condition complexity.
Q4. Doesn’t this double the API calls?
That’s what the refactor solved. Storage logic was split into store_snapshots_from_holdings() so a single fetch feeds both snapshot storage and the loss check.
Pingback: Dual-Channel Alerts: Telegram + SMTP Failover | Toss Auto-Trading Dev Journal #21 - Orbit - Space & ETF Investing
Pingback: Toss Securities Holdings Dashboard | Dev Journal #25 - Orbit - Space & ETF Investing