Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,8 @@ They are retained (not removed) because both become meaningful under a two-sided
- `--imbalance-guard-threshold`: Cancel one side when L2 book is skewed (0–1, default: 0)
- `--close-refresh-threshold-bps`: Refresh close orders on BBO change to improve maker fill rate (default: 0 = disabled)

**Max adverse excursion logging** (`track_max_adverse_excursion`, default on): Records the worst unrealized loss each position reaches while it is open and appends `mae_bps` (positive = moved against us) and `mae_age` (position age when that worst point occurred) to the `[close-reason]` log line. Emitted for every close reason, including profitable maker closes, because the positions that drifted and then recovered are exactly what an unrealized-loss threshold would have closed out early — so they are the denominator when calibrating `unrealized_loss_close_bps`, and fills alone cannot show them. Observation only: it never influences a close decision, and samples come from a cache-only market-data read, so it adds no API calls. Two caveats for consumers: sampling happens once per main-loop cycle, making the value a **lower bound** on the true excursion (an intra-cycle spike that retraces is not seen, so counts of positions breaching a level are under-counted); and the fields append at the end of the line, so existing `last_tier` / `max_age` log parsers keep working unchanged.

**Oracle divergence guard** (`--oracle-guard`): Subscribes to the `activeAssetCtx` stream and uses the HIP-3 `oraclePx` feed (deployer-pushed every ~3s, each update capped at 1% of the previous value) as a defensive signal against stale-quote sniping. Two gates: a **divergence gate** cancels the stale side's entry quotes when `|book mid − oraclePx|` exceeds `--oracle-divergence-threshold-bps` (oracle above mid → resting SELLs are stale-cheap → cancel sells, and vice versa), and a **momentum gate** cancels both sides and blocks placement for `--oracle-guard-block-seconds` when a single oracle step pins near the 1% cap (`--oracle-momentum-cap-pin-bps`) or `--oracle-momentum-consecutive` same-direction steps of at least `--oracle-momentum-min-step-bps` occur. A staleness TTL (`--oracle-stale-ttl-seconds`) doubles as automatic market-close detection: when the oracle value stops changing (e.g. equity perps outside US hours), the guard fully disarms so a floating book is not mistaken for divergence, and re-baselines on recovery so the post-open gap never counts as a step. Cancels affect entry orders only (close orders are untouched), and any missing data — no ctx received, WS down, unparseable oracle price — leaves the guard inert. Default disabled.

**Adverse selection logging** (`--enable-adverse-selection-log`): Measures mid-price movement 5s/30s/60s after each fill, logging per-coin summaries every 300s. Observation only — no trading impact.
Expand Down Expand Up @@ -615,6 +617,7 @@ strategies:
close_tier_toxicity_min_fills: 5 # --close-tier-toxicity-min-fills (min fills in window required to fire)
close_tier_toxicity_floor_pct: 0.15 # --close-tier-toxicity-floor-pct (breakeven fraction floor after multiplier)
unrealized_loss_close_bps: 0 # --unrealized-loss-close-bps (early taker close when unrealized loss exceeds this bps; 0 = disabled)
track_max_adverse_excursion: true # (no CLI flag; JSON config only) log mae_bps/mae_age on the close-event line; observation only, no extra API calls
bbo_mode: false # --bbo-mode (place orders at best bid/ask instead of mid ± spread)
bbo_offset_bps: 0 # --bbo-offset-bps (bps behind BBO; 0 = at BBO)
inventory_skew_bps: 0 # --inventory-skew-bps (NO-OP in the current single-sided flow: a coin holding a position does not re-quote, so the skew never reaches an order; warns at startup if non-zero)
Expand Down
10 changes: 10 additions & 0 deletions market_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,16 @@ def _parse_levels(self, coin: str, levels) -> Optional[MarketData]:
micro_price=micro_price,
)

def get_cached_market_data(self, coin: str) -> Optional[MarketData]:
"""Return the cached snapshot for *coin*, or ``None`` when not fresh.

Unlike :meth:`get_market_data` this never falls back to a REST L2
fetch, so callers that only want an opportunistic read (observation,
logging) cannot add API load. Returns ``None`` on a cache miss — the
caller is expected to skip rather than fetch.
"""
return self._cache.get(coin)

def update_from_ws(self, coin: str, levels) -> None:
"""Update the cache from a WebSocket l2Book message.

Expand Down
1 change: 1 addition & 0 deletions strategies/market_making_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ def __init__(self, market_data_manager, order_manager, config: Dict) -> None:
coin_close_tier_overrides=self.cfg.per_coin.close_tier,
close_tier_toxicity=self.cfg.close_tier_toxicity,
close_tier_min_seconds=self.cfg.close.tier_min_seconds,
track_max_adverse_excursion=self.cfg.close.track_max_adverse_excursion,
)

@property
Expand Down
8 changes: 8 additions & 0 deletions strategies/mm_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,11 @@ class CloseConfig:
# against over-shortening when dynamic age, per-coin overrides and
# toxicity acceleration stack. 0 = disabled (legacy behaviour).
tier_min_seconds: float = 0.0
# Log the worst unrealized loss each position reached (mae_bps/mae_age on
# the close-event line). Observation only — no effect on close decisions
# and no extra API calls, so it defaults on. Needed to calibrate
# unrealized_loss_close_bps, which cannot be done from fills alone.
track_max_adverse_excursion: bool = True

def __post_init__(self) -> None:
if self.tier_min_seconds < 0:
Expand Down Expand Up @@ -621,6 +626,9 @@ def from_legacy_dict(cls, d: Dict) -> "MMConfig":
unrealized_loss_close_bps=float(d.get('unrealized_loss_close_bps', 0.0)),
force_close_max_loss_bps=float(d.get('force_close_max_loss_bps', 0.0)),
tier_min_seconds=float(d.get('close_tier_min_seconds', 0.0)),
track_max_adverse_excursion=bool(
d.get('track_max_adverse_excursion', True)
),
),
close_tier_toxicity=CloseTierToxicityConfig(
enabled=bool(d.get('close_tier_toxicity_enabled', False)),
Expand Down
69 changes: 69 additions & 0 deletions strategies/mm_position_closer.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def __init__(
coin_close_tier_overrides: Optional[Dict[str, Tuple[float, float]]] = None,
close_tier_toxicity: Optional[CloseTierToxicityConfig] = None,
close_tier_min_seconds: float = 0.0,
track_max_adverse_excursion: bool = True,
) -> None:
self.order_manager = order_manager
self.market_data = market_data
Expand Down Expand Up @@ -99,9 +100,18 @@ def __init__(
self._tox_active: Dict[str, bool] = {}
# Absolute floor (seconds) for the breakeven transition (0 = disabled)
self.close_tier_min_seconds = close_tier_min_seconds
# Observation only: track the worst unrealized loss each position
# reaches while it is open, so the close-event log can report it.
self._track_mae = track_max_adverse_excursion

# coin -> (entry_time, close_oid or None, close_tier)
self._open_positions: Dict[str, Tuple[float, Optional[int], int]] = {}
# coin -> (max_adverse_excursion_bps, position_age_when_reached).
# Seeded to (0.0, 0.0) when a position is first tracked so a position
# that never moved against us is recorded as 0.0 rather than being
# omitted — the recovered positions are the denominator when
# calibrating the unrealized-loss close threshold.
self._mae: Dict[str, Tuple[float, float]] = {}
# coin -> consecutive cycles where manage() failed to place a close order
self._consecutive_close_failures: Dict[str, int] = {}
# coin -> last effective_max_age seen by manage(); used so close-event
Expand Down Expand Up @@ -142,11 +152,21 @@ def _record_close(
clamped low) or by the full grace window. Optional and
backward-compatible — the existing log regex
``last_tier=(\\S+)`` still matches the same group.

``mae_bps`` / ``mae_age`` report the worst unrealized loss the
position reached and the age at which it did. Emitted for every
close reason, including profitable maker closes, because a
threshold-based early close can only be calibrated against the
positions that drifted and then recovered. Appended at the end of
the line for the same backward-compatibility reason as ``max_age``.
"""
self._close_stats[reason] += 1
self._close_stats_by_coin[coin][reason] += 1
tier_name = _TIER_NAMES.get(tier, f"tier{tier}")
suffix = f" max_age={effective_max_age:.0f}s" if effective_max_age is not None else ""
mae = self._mae.pop(coin, None)
if mae is not None:
suffix += f" mae_bps={mae[0]:.1f} mae_age={mae[1]:.0f}s"
logger.info(
f"[close-reason] {coin} reason={reason} age={age:.0f}s "
f"last_tier={tier_name}{suffix}"
Expand Down Expand Up @@ -276,6 +296,8 @@ def manage(self, coin: str, position: Dict, close_position_fn,
# Register position if not tracked
if coin not in self._open_positions:
self._open_positions[coin] = (now, None, _TIER_NORMAL)
if self._track_mae:
self._mae[coin] = (0.0, 0.0)
logger.info(f"[mm] Tracking position for {coin}: size={size:.6f} entry={entry_price:.4f}")

entry_time, close_oid, current_tier = self._open_positions[coin]
Expand All @@ -285,6 +307,11 @@ def manage(self, coin: str, position: Dict, close_position_fn,
# on_position_closed paths that don't otherwise know it.
self._last_effective_max_age[coin] = effective_max_age

# Sample the running max adverse excursion before any close decision
# below, so the reading reported with the close event includes this
# cycle. Observation only.
self._update_mae(coin, size, entry_price, age)

# Unrealized loss early close: taker close when loss exceeds threshold.
# Uses the per-coin override if present, else the global threshold.
unrealized_loss_threshold = self._get_unrealized_loss_bps_for_coin(coin)
Expand Down Expand Up @@ -378,6 +405,7 @@ def manage(self, coin: str, position: Dict, close_position_fn,
)
self._open_positions.pop(coin, None)
self._consecutive_close_failures.pop(coin, None)
self._mae.pop(coin, None)
else:
self._consecutive_close_failures.pop(coin, None)

Expand Down Expand Up @@ -408,6 +436,7 @@ def _handle_force_close(
)
self._open_positions.pop(coin, None)
self._consecutive_close_failures.pop(coin, None)
self._mae.pop(coin, None)
return
except Exception as e:
logger.debug(f"[mm] Could not verify position for {coin} before force close: {e}")
Expand Down Expand Up @@ -538,6 +567,45 @@ def _get_unrealized_loss_bps_for_coin(self, coin: str) -> float:
return self._coin_unrealized_loss_overrides[bare]
return self.unrealized_loss_close_bps

def _update_mae(self, coin: str, size: float, entry_price: float, age: float) -> None:
"""Update the running max adverse excursion for an open position.

Observation only — never influences a close decision. Uses the
cache-only market-data read so an enabled-by-default observation
cannot add API load: on a cache miss the sample is skipped rather
than fetched.

``mae_bps`` uses the same sign convention as the unrealized-loss
early close: positive means the position has moved against us. The
value is floored at 0.0 rather than going negative when a position
only ever moved in our favour, so the floor does not depend on the
seeding path having run.

Sampling is bounded by the main-loop interval, so the recorded
value is a *lower bound* on the true excursion — an intra-interval
spike that retraced before the next cycle is not seen. Consumers
calibrating a threshold against this must treat counts of positions
that breached a level as under-counted.
"""
if not self._track_mae or entry_price <= 0 or size == 0:
return
md = self.market_data.get_cached_market_data(coin)
if md is None or md.mid_price <= 0:
return
if size > 0: # long: adverse when the mid falls below entry
adverse_bps = (entry_price - md.mid_price) / entry_price * 10_000
else: # short: adverse when the mid rises above entry
adverse_bps = (md.mid_price - entry_price) / entry_price * 10_000
prev = self._mae.get(coin)
# Floor at 0.0 so a position that only moved in our favour cannot
# record a negative "adverse" excursion. Relying on the registration
# seed for this would break whenever _update_mae runs without it --
# _record_close pops the entry and can fire on the WS thread while a
# manage() call is already past the registration check.
floor = prev[0] if prev is not None else 0.0
if adverse_bps > floor:
self._mae[coin] = (adverse_bps, age)

def set_adverse_tracker(self, tracker: Any) -> None:
"""Register the AdverseSelectionTracker for toxicity-linked tiers.

Expand Down Expand Up @@ -670,6 +738,7 @@ def _place_take_profit(
logger.info(f"[mm] Position for {coin} already closed, skipping close order")
self._open_positions.pop(coin, None)
self._consecutive_close_failures.pop(coin, None)
self._mae.pop(coin, None)
return False
except Exception:
pass # Proceed with placement — rejection is safer than missing a close
Expand Down
3 changes: 3 additions & 0 deletions tests/test_close_reason_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
def _make_closer(max_age=120, maker_only=True, taker_fallback=None, spread_bps=10):
om = MagicMock()
md = MagicMock()
# Cache miss by default: manage() samples market data unconditionally
# for max-adverse-excursion tracking, which is observation-only.
md.get_cached_market_data.return_value = None
md.round_size.return_value = 0.5
md.get_sz_decimals.return_value = 0
md.price_rounding_params.return_value = (0, True)
Expand Down
3 changes: 3 additions & 0 deletions tests/test_dynamic_close_pricing.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ def _make_closer(max_age=120, taker_fallback=120, aggressive_loss_bps=1.0,
"""Create a PositionCloser with mocked dependencies."""
om = MagicMock()
md = MagicMock()
# Cache miss by default: manage() samples market data unconditionally
# for max-adverse-excursion tracking, which is observation-only.
md.get_cached_market_data.return_value = None
closer = PositionCloser(
order_manager=om, market_data=md,
spread_bps=10, max_position_age_seconds=max_age,
Expand Down
3 changes: 3 additions & 0 deletions tests/test_ghost_position_fix.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ def test_different_dex_cache_independent(self):
def _make_closer(max_age=120, maker_only=False, spread_bps=10):
om = MagicMock()
md = MagicMock()
# Cache miss by default: manage() samples market data unconditionally
# for max-adverse-excursion tracking, which is observation-only.
md.get_cached_market_data.return_value = None
md.round_size.return_value = 0.5
closer = PositionCloser(
order_manager=om,
Expand Down
6 changes: 6 additions & 0 deletions tests/test_mm_position_closer.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ def _make_closer(max_age=120, maker_only=True, taker_fallback=None,
spread_bps=10):
om = MagicMock()
md = MagicMock()
# Cache miss by default: manage() samples market data unconditionally
# for max-adverse-excursion tracking, which is observation-only.
md.get_cached_market_data.return_value = None
md.round_size.return_value = 0.5
md.get_sz_decimals.return_value = 0
md.price_rounding_params.return_value = (0, True)
Expand Down Expand Up @@ -349,6 +352,9 @@ def _make_tier_closer(max_age=120, coin_tiers=None, tox_cfg=None, tracker=None,
maker_only=False):
om = MagicMock()
md = MagicMock()
# Cache miss by default: manage() samples market data unconditionally
# for max-adverse-excursion tracking, which is observation-only.
md.get_cached_market_data.return_value = None
md.round_size.return_value = 0.5
md.price_rounding_params.return_value = (0, True)
om.get_all_positions.return_value = [{'coin': 'BTC', 'szi': '1.0'}]
Expand Down
Loading
Loading