My Exit Rule Said '2 Periods' — My Code Was Counting Something Else Entirely
My funding-rate bot has an exit rule that reads like plain English:
exit_below_threshold_periods = 2
# "exit if the funding rate stays below threshold for 2 periods"
Two periods. Clear. The problem was the code, which counted this:
if rate < threshold:
state["below_threshold_count"] += 1 # ...once per scan
if state["below_threshold_count"] >= EXIT_PERIODS:
exit_position()
Once per scan. Not once per period. The bot scans about once an hour, so the counter was measuring scans (~1h each) while the config was talking about funding periods — which on some contracts are 8 hours.
Two scans is about two hours. Two funding periods is sixteen hours. So on 8-hour contracts my "2 period" rule was firing roughly 8× too early, on a counter I'd named below_threshold_count and therefore never questioned.
Why it survived so long
Here's the part that stung. The rule was correct for my hourly contracts. An hourly contract has a 1-hour period, the scan loop runs about hourly, so "2 scans" really is "2 periods." The bug was invisible on exactly the contract I looked at when I wrote it, and wrong by 8× on the contracts I added later.
That's the general shape of this class of bug: the unit of the name and the unit of the increment drift apart, and something in your test data quietly agrees with the wrong one. A counter named periods that increments per loop is only correct while loop-interval happens to equal period-length. Nothing in the code enforces that. The name just suggests it.
I've written before about a breaker that read a number which never moved. This is the same family: the label was right, the thing being measured was not what the label said.
Fixing it by deriving, not counting
The fix is to stop incrementing and start deriving. Instead of "how many times did the loop run," ask "how much time has passed, and how many periods is that?"
def _acc_periods_since(since, now, period_h):
if not since:
return 0
elapsed_h = (now - since).total_seconds() / 3600.0
return int(elapsed_h / period_h)
# on the exit site:
if rate < threshold:
state.setdefault("below_threshold_since", now_iso())
else:
state["below_threshold_since"] = None
if _acc_periods_since(state.get("below_threshold_since"),
now, _funding_period_hours(contract)) >= EXIT_PERIODS:
exit_position()
Two changes matter here beyond the arithmetic:
-
The period length comes from the contract, not from a constant.
_funding_period_hours(contract)returns 1 for hourly, 8 for 8-hour. The counter now scales with the thing it's named after. -
There's a fallback that reuses the same
period_h. If the module that knows the interval is missing, the helper still computes with the real period rather than silently reverting to a hardcoded 8. A fallback that guesses a different unit is worse than no fallback — it's how you get the original bug back, wearing a safety hat.
After the swap: an 8-hour contract's "2 periods" is 16h, and an hourly contract's is 2h. Each one correct for its own interval. The rule finally means what the config says.
The bug the fix uncovered
I shipped the change, then sent it to two external reviewers — a Grok pass and a Hermes pass — before rolling it to the live path. Both came back "accept with conditions," and both independently flagged the same medium-severity issue, which is the kind of agreement that makes you sit up.
The problem was in the reset. My new code only cleared below_threshold_since in the else-branch — the "rate is healthy" path. But there are early returns before that point: a flip-cooldown guard and a minimum-hold guard can both return early, skipping the healthy-path reset entirely. So the sequence was:
- Scan A: rate healthy (+0.20%), but the position is in a cooldown → early return,
below_threshold_sincenever cleared. - Scan B: rate dips below threshold (−0.03%) → the stale
sincefrom an earlier dip is still there, so the elapsed time already exceeds 2 periods → premature exit.
I reproduced it at the code level, not just in theory: a long position, one healthy scan during cooldown, then one weak scan, and it exited. The fix was to move the reset before the early returns, in both the paper and live traders.
This is the second time in a month that a "small counter refactor" turned out to have a control-flow seam in it. Counters look like data. They're actually state, and state has to be maintained on every path, including the ones that bail out early.
How I now test a counter
The checklist is short and I've started applying it to anything that counts anything:
-
Write the unit next to the name.
below_threshold_countis a lie;below_threshold_periodsorbelow_threshold_sinceis not. If the name doesn't say what unit it's in, assume a future reader will get it wrong — that reader was me. - Derive counts from timestamps, never from loop iterations. Loop iterations measure your scheduler, not the world.
- Prove the rule with two intervals. The original bug only existed for 8h contracts while being invisible for 1h. If your test suite only has one contract type, you have a blind spot shaped exactly like the bug. My harness now has an explicit case — "2 scans on an 8-hour contract must not exit."
-
Reset the clock on every path, including early returns. A
continue/returnthat skips your state maintenance is a time bomb. - Mutate the fix and confirm the test goes red. I removed the fix block on purpose; the new test failed. That's the only proof that the test is testing anything.
The honest summary: the arithmetic fix was maybe ten lines. The finding — that a counter named periods was counting scans, and that its reset was skipped on early returns — is the part worth keeping. The code got smaller and more literal. The process got a rule:
a number should be derived from the thing it claims to measure, and the name has to match the measurement.
I'd genuinely like to know how common this is: has anyone else shipped a counter or a threshold whose unit was fine in the case you tested and wrong in the case you didn't? Config values that say "periods," "minutes," or "count" but are really "loop iterations" feel like they're everywhere. Drop your version in the comments.
Part of my Building in Public series — previously: my drawdown breaker replayed to zero fires because it watched a number that never moved, my margin guard was reading zero, and the assertion that catches runs that do nothing.
Top comments (5)
The early-return reset is the part I would keep in the test. Also distinguish sixteen elapsed hours from two completed funding settlements: a weak reading halfway through a funding period starts those clocks at different points. If the rule means completed settlements, record their timestamps and test a below-threshold start just before a boundary.
The early-return reset went in first, because it was the one I could prove: a healthy scan that exits through the flip-cooldown or the min-hold gate used to leave the old timestamp standing, so the next below-threshold scan measured from a stale start and closed early. The test that pins it does exactly what you'd expect - healthy scan through the early return, then a below-threshold scan, and the second one must not exit.
Your second point is the one I got wrong, so thank you for the push. I implemented the elapsed-hours version, not the completed-settlements version.
periods = (now - since) / period_h- which is what the post shows - and it only agrees with a settlement count when the clock starts exactly on a boundary.The divergence is small but real and easy to state: on an 8h contract, start the clock at 07:59 and check at 16:01. Elapsed says 1.004 periods, settlements crossed says 2. Same interval, one period apart in verdict, purely because the start was a minute before a boundary. So a position that goes below threshold just before a settlement gets one extra period of grace it did not earn, and one that starts just after gets none.
If the rule means "two settlements have paid out and both were weak", then elapsed hours is the wrong instrument and the timestamp list is the right one - and the fix is not a division, it is recording the settlement times you actually observed and counting the weak ones. I haven't made that change; it is a bigger one than I want to smuggle into a bug fix, and it changes the semantics of the rule, not just its arithmetic.
Where I overclaimed a minute ago, correcting it: my tests do not pin the boundary case yet. They pin the unit change and the early-return reset, both under the elapsed-hours definition - so they would pass under either meaning and say nothing about the one that matters. Your question found the gap, and adding that test is the next thing I do on this rule.
That split makes sense. I would keep the elapsed-hours tests and add a separate settlement fixture before changing the rule. Start weak at 07:59, then record weak settlements at 08:00 and 16:00. At 16:01 that is two observed weak settlements, but only one complete eight-hour interval.
I would also repeat the same settlement ID and restart between the two events: neither should count the first event twice. A missing settlement record should stay unknown rather than becoming a weak settlement by inference. Those cases pin the new meaning without suggesting that the current elapsed-hours fix already implements it.
That split is the right shape, and it is the one I was missing: keep the elapsed tests where they are, add a settlement fixture beside them, and let the two definitions disagree in public before anyone touches the rule.
Your 07:59 case is exactly the wedge. Weak at 07:59, weak settlements recorded at 08:00 and 16:00, checked at 16:01: two observed weak settlements, one complete eight-hour interval. Elapsed says 1.004 and settlements crossed says 2 on the same interval, so the fixture fails against today's code by construction rather than by accident - which is what I want from it.
Both idempotency cases are in: the same settlement id replaying across a scan, and a restart between the two events. Neither may count the first settlement twice, and neither is covered by anything I have now, because nothing currently keys on settlement identity at all.
The one I would argue hardest for is the last: a missing settlement record stays unknown. It is the same rule as the margin guard - absence must not become a value - and it is the place where a settlement-counting implementation goes wrong quietly, by treating a gap in the record as a weak period and closing a position on evidence it never had.
To be clear about status, since you put it precisely: these cases describe what the rule should mean. They are not in the repo yet, and the current elapsed-hours code does not implement them. I am writing them first, red, before the semantics change - otherwise the change is unfalsifiable.
I will adapt and let you know! Thanks!