feat: Add DYNAMIC_AGE clamp stats and close-reason max_age field#142
Conversation
Two small observability additions for diagnosing taker-fallback hot spots: 1. Per-coin clamp counters in `_get_dynamic_position_age` (min_clamp, max_clamp, mid, plus pre-clamp raw avg/min/max) emitted in the periodic `_log_dynamic_age` summary as "[mm] dyn-age <coin> samples=N min=X% mid=X% max=X% raw_avg=Ns raw_range=[Ms-Ks]" — one line per coin, sorted by min_clamp pct descending. A coin near 100% min tells operators DYNAMIC_AGE_MIN is the binding constraint vs. a structurally short grace window. 2. Optional `effective_max_age` argument on `_record_close`, surfaced as ` max_age=Ns` at the end of each [close-reason] log line. Plumbed through `manage()` (using the dynamic value already in scope) and propagated to externally-driven close paths (cleanup_closed, on_position_closed) via a new `_last_effective_max_age` snapshot dict. The trailing field is appended after the existing `last_tier=` so the existing close-event regex used by /review continues to match. No behavior change; pure observability.
keitaj
left a comment
There was a problem hiding this comment.
Review: DYNAMIC_AGE clamp stats and close-reason max_age field
Pure-observability change. No strategy behavior is touched; the new field on [close-reason] is appended after last_tier= so the existing last_tier=(\S+) regex still captures the same group (verified by test_existing_close_reason_regex_still_matches). Thread-safety of the new _last_effective_max_age dict mirrors the existing _open_positions pattern (atomic dict ops, no lock needed across main + WS threads). The _log_dynamic_age timer is now advanced after a successful emit instead of before — a minor improvement that means a logger exception leaves the next cycle to retry rather than silently skipping.
1. Boundary classification at the clamp endpoints (nit)
strategies/market_making_strategy.py (clamp classification block):
if raw_age <= self._dynamic_age_min:
stats["min_clamp"] += 1
elif raw_age >= self._dynamic_age_max:
stats["max_clamp"] += 1
else:
stats["mid"] += 1<= and >= mean a raw_age exactly equal to the floor/ceiling counts as a clamp even though no clamping happened. Operationally fine — "value is sitting at the floor" is exactly the signal operators want. Worth keeping in mind only if someone later expects strict-clamp counts (e.g. for a unit-test on a specifically-calibrated baseline). No change requested.
2. Defensive .get("samples", 0) (nit)
strategies/market_making_strategy.py (_log_dynamic_age):
samples = int(stats.get("samples", 0))stats was created via setdefault with all keys including samples, so direct stats["samples"] would never KeyError. Defensive .get is harmless but slightly noisy. Not worth changing.
Verdict: LGTM
No blockers, two nits, both ignorable. Existing close-event regex backward-compat is explicitly tested. CI green per author note; will re-run before merge.
Summary
DYNAMIC_AGEsummary log so operators can see at a glance whetherDYNAMIC_AGE_MINis the binding constraint for a coin (min=85%) or the value moves freely.effective_max_ageargument to_record_closeand surfaces it as a trailingmax_age=Nson each[close-reason]log line. This lets post-hoc analysis tell apart a long-aged force-close caused by a tighteffective_max_age(DYNAMIC_AGE clamped low) from one that ran the full grace window.last_tier=so the close-event regex used downstream still matches.Changes
strategies/market_making_strategy.py_dynamic_age_clamp_statsdict (per-coin: min_clamp, max_clamp, mid, raw_sum, raw_min, raw_max, samples)._get_dynamic_position_ageincrements the right counter on each compute._log_dynamic_ageemits one[mm] dyn-age <coin> samples=… min=…% mid=…% max=…% raw_avg=…s raw_range=[…-…]line per coin in addition to the existing snapshot. Sorted bymin_clamppct descending. Counters reset after each emit.strategies/mm_position_closer.py_record_closegets an optionaleffective_max_ageparameter; emitsmax_age=Nssuffix when supplied.manage()(unrealized loss path, force-close path) uses theeffective_max_agealready computed;cleanup_closedandon_position_closedpull it from a new_last_effective_max_agesnapshot dict populated eachmanage()cycle. The dict is evicted on close.test_dynamic_age_log.py: newTestDynamicAgeClampStatsclass covering counter increments for the three clamp regions, summary line format, sort order, and reset behavior. Existing fixture updated to initialize the new dict.test_close_reason_logging.py: newTestRecordCloseEffectiveMaxAgeandTestEffectiveMaxAgePropagationcovering field presence/absence, regex backward compatibility, and the manage→cleanup_closed propagation path.test_volatility_position_age.py: fixture updated for the new dict.Test plan
flake8(max-line-length 120) passes on the full repo.pytest tests/ -qpasses (930 tests, +12 new).[close-reason]regexlast_tier=(\\S+)still captures the same group on the new log format (verified bytest_existing_close_reason_regex_still_matches).🤖 Generated with Claude Code