From 595fc57b2676567ab29d2c8826d3ca3053d19b2a Mon Sep 17 00:00:00 2001 From: cardosofede Date: Wed, 30 Sep 2026 16:27:38 +0700 Subject: [PATCH 1/2] fix(risk): measure executor exposure in quote, not base units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A position/order executor's config `amount` is in BASE currency, but build_executor_row returned it unpriced as the row's `amount`, which the executors provider sums into `total_exposure`. That figure is the drawdown's denominator and the book the position cap adds new creates to. A ~$200 LTC position counted as $2.90, so a $0.70 loss read as a 24% drawdown and fired the shutdown kill switch. The error scales with price: a $300 BTC position counted as $0.003 (cap effectively bypassed), and low-priced coins inflate exposure (kill switch too insensitive, false cap refusals). The row's `amount` is now always quote: base amounts are priced at entry (or the order's limit price, or the fill price for market orders) — the same rule the risk gate prices a create with — falling back to the filled quote and never to the raw base figure. DCA's `amounts_quote` ladder, previously read as 0, is summed. --- condor/fetchers/executors.py | 49 +++++++++- tests/test_executor_row_amount_quote.py | 116 ++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 tests/test_executor_row_amount_quote.py diff --git a/condor/fetchers/executors.py b/condor/fetchers/executors.py index 5bd2a1960..11391628d 100644 --- a/condor/fetchers/executors.py +++ b/condor/fetchers/executors.py @@ -134,6 +134,51 @@ def get_executor_fees(executor: Dict[str, Any]) -> float: return 0.0 +def _quote_size( + ex: Dict[str, Any], + cfg: Dict[str, Any], + custom_info: Dict[str, Any], + entry_price: float, + current_price: float, +) -> float: + """An executor's size in QUOTE currency -- the row's ``amount``. + + Each executor type sizes itself in a different field and a different unit: + + - grid: ``total_amount_quote``, already quote. + - dca: the ``amounts_quote`` ladder, already quote. + - position / order: ``amount`` in BASE currency, priced at the entry (or the + order's limit ``price``, or the live price for a market order) -- the same + base-times-price rule the risk gate prices a create with + (``condor.agents.risk._planned_amount_quote``), so the book it reads next + tick agrees with what it approved. + - lp: ``custom_info.total_value_quote``, the position's current value. + + A base ``amount`` with no price to value it at falls back to the quote the + executor reports as filled, and otherwise to 0: it is never returned as-is. + Summed as exposure, a base figure made 2.9 LTC read as $2.90, so a $0.70 + loss on a ~$200 position read as a 24% drawdown and tripped the kill switch. + """ + total = float(cfg.get("total_amount_quote") or 0) + if total > 0: + return total + ladder = cfg.get("amounts_quote") + if isinstance(ladder, list) and ladder: + try: + total = sum(float(v or 0) for v in ladder) + except (TypeError, ValueError): + total = 0.0 + if total > 0: + return total + base = float(cfg.get("amount") or 0) + if base > 0: + price = entry_price or float(cfg.get("price") or 0) or current_price + if price > 0: + return base * price + return float(ex.get("filled_amount_quote") or 0) + return float(custom_info.get("total_value_quote") or 0) + + def build_executor_row(ex: Dict[str, Any]) -> Dict[str, Any]: """Canonical display row for one raw executor. @@ -192,9 +237,7 @@ def build_executor_row(ex: Dict[str, Any]) -> Dict[str, Any]: current_price = _candidate break - amount = float(cfg.get("total_amount_quote") or cfg.get("amount") or 0) - if amount <= 0: - amount = float(custom_info.get("total_value_quote") or 0) + amount = _quote_size(ex, cfg, custom_info, entry_price, current_price) return { "id": str(ex.get("id") or ex.get("executor_id") or ""), diff --git a/tests/test_executor_row_amount_quote.py b/tests/test_executor_row_amount_quote.py new file mode 100644 index 000000000..84e659cce --- /dev/null +++ b/tests/test_executor_row_amount_quote.py @@ -0,0 +1,116 @@ +"""An executor row's ``amount`` is its size in QUOTE currency, for every type. + +The providers sum it into ``total_exposure``, which is both the drawdown's +denominator (``JournalManager.get_drawdown_pct``) and the book the position cap +adds a new create to (``RiskEngine.check_executor_action``). A position/order +executor's config ``amount`` is BASE, and it used to flow through unpriced: a +~$200 LTC position counted as $2.90, so a $0.70 loss read as a 24% drawdown and +tripped the kill switch -- while a $300 BTC position counted as $0.003 and left +the $500 cap effectively open. +""" + +import asyncio + +import pytest + +from condor.agents.journal import JournalManager +from condor.agents.performance import AgentPerformance, _executor_row +from condor.agents.providers.executors import ExecutorsProvider +from condor.fetchers.executors import build_executor_row + + +def _ltc_position(**overrides): + ex = { + "id": "ltc-1", + "status": "RUNNING", + "filled_amount_quote": 200.1, + "config": { + "type": "position_executor", + "connector_name": "bitget_perpetual", + "trading_pair": "LTC-USDT", + "side": "BUY", + "amount": 2.9, + "entry_price": 69.0, + }, + "custom_info": {"current_price": 68.76}, + } + ex.update(overrides) + return ex + + +def test_position_executor_base_amount_is_priced_at_entry(): + assert build_executor_row(_ltc_position())["amount"] == pytest.approx(200.1) + + +def test_market_position_without_entry_is_priced_at_average_fill(): + ex = _ltc_position() + del ex["config"]["entry_price"] + ex["custom_info"]["current_position_average_price"] = 70.0 + + assert build_executor_row(ex)["amount"] == pytest.approx(203.0) + + +def test_limit_order_executor_is_priced_at_its_limit_price(): + ex = { + "config": {"type": "order_executor", "amount": 0.003, "price": 100_000.0}, + } + + assert build_executor_row(ex)["amount"] == pytest.approx(300.0) + + +def test_market_order_executor_is_priced_at_its_fill(): + ex = { + "config": {"type": "order_executor", "amount": 0.003}, + "custom_info": {"held_position_orders": [{"price": 100_000.0}]}, + } + + assert build_executor_row(ex)["amount"] == pytest.approx(300.0) + + +def test_unpriceable_base_amount_falls_back_to_filled_quote_never_base(): + ex = {"filled_amount_quote": 150.0, "config": {"amount": 2.9}} + assert build_executor_row(ex)["amount"] == 150.0 + + assert build_executor_row({"config": {"amount": 2.9}})["amount"] == 0.0 + + +@pytest.mark.parametrize( + "ex,expected", + [ + ({"config": {"type": "grid_executor", "total_amount_quote": 300}}, 300.0), + ({"config": {"type": "dca_executor", "amounts_quote": [50, "50"]}}, 100.0), + ( + { + "config": {"type": "lp_executor"}, + "custom_info": {"total_value_quote": 9.97}, + }, + 9.97, + ), + ], +) +def test_quote_denominated_types_are_read_as_is(ex, expected): + assert build_executor_row(ex)["amount"] == pytest.approx(expected) + + +def test_ltc_loss_is_measured_against_its_quote_size(monkeypatch, tmp_path): + """The reported incident, end to end: provider exposure -> journal drawdown.""" + + async def _fake(client, agent_id, **_kw): + return AgentPerformance( + agent_id=agent_id, executors=[_executor_row(_ltc_position())] + ) + + monkeypatch.setattr("condor.agents.performance.fetch_agent_performance", _fake) + result = asyncio.run(ExecutorsProvider().execute(object(), {}, agent_id="a_1")) + exposure = result.data["total_exposure"] + assert exposure == pytest.approx(200.1) + + journal = JournalManager("a_1", session_dir=tmp_path) + journal.record_snapshot( + total_pnl=0.0, total_volume=200, open_count=1, position_size=exposure + ) + journal.record_snapshot( + total_pnl=-0.70, total_volume=200, open_count=1, position_size=exposure + ) + + assert journal.get_drawdown_pct() == pytest.approx(0.35, abs=0.01) From 756e90b2e21967f5eb1d6df9bf02dcce7932dbdd Mon Sep 17 00:00:00 2001 From: cardosofede Date: Thu, 1 Oct 2026 15:54:19 +0700 Subject: [PATCH 2/2] fix(risk): keep a pending market order's exposure on the next tick A market order still pending at the next tick reports no entry, no limit price and no fill, so its row was sized at 0 and the position cap approved a further create the two together exceed. The executors provider now prices such a row at the live price, the same rule the risk gate priced the create with. When no price is available the executor is reported as unpriced and the tick marks the book untrusted, so creates are refused while stops still pass. --- condor/agents/engine.py | 10 +++++ condor/agents/providers/executors.py | 47 +++++++++++++++++++++ condor/fetchers/executors.py | 2 + tests/test_executor_row_amount_quote.py | 55 +++++++++++++++++++++++++ 4 files changed, 114 insertions(+) diff --git a/condor/agents/engine.py b/condor/agents/engine.py index bff3aeec9..6fbc46169 100644 --- a/condor/agents/engine.py +++ b/condor/agents/engine.py @@ -647,6 +647,16 @@ async def _gather_tick_context(self, client) -> _TickContext | None: risk_state.total_exposure = float( self._last_skill_data.get("total_exposure", 0.0) or 0.0 ) + # A pending order nothing could price is exposure of unknown size, not + # zero: the book cannot size a create, so it refuses them like any + # other missing metric while every brake still passes. + unpriced = self._last_skill_data.get("unpriced_exposure") or [] + if unpriced: + risk_state.book_trusted = False + risk_state.drift_reason = ( + f"no price for pending executor(s) {', '.join(unpriced)}, " + "so their exposure is unknown" + ) self._apply_drift_verdict(risk_state, drift_result) # Hard kill-switch: escalate to an emergency winddown before the soft diff --git a/condor/agents/providers/executors.py b/condor/agents/providers/executors.py index 0d0e7a733..468644fc9 100644 --- a/condor/agents/providers/executors.py +++ b/condor/agents/providers/executors.py @@ -20,6 +20,45 @@ log = logging.getLogger(__name__) +async def _price_pending_rows(client: Any, running: list[dict]) -> list[str]: + """Value the running rows the row builder could not, at the live price. + + A market order still pending at the next tick reports no entry, no limit + price and no fill, so ``build_executor_row`` -- pure, no client -- sizes it + at 0. The risk gate priced that same create at the live price before + approving it (``condor.agents.risk._planned_amount_quote``); dropping it + from the book one tick later let a second create through a position cap the + two together exceed. So it is priced here the way the gate priced it. + + Rows are updated in place. Returns the ids that still have no price: their + exposure is unknown, not zero, and the caller must say so. + """ + from condor.fetchers.market_data import fetch_current_price + + prices: dict[tuple[str, str], float] = {} + unpriced: list[str] = [] + for row in running: + if row.get("amount", 0) > 0: + continue + try: + base = float((row.get("config") or {}).get("amount") or 0) + except (TypeError, ValueError): + base = 0.0 + if base <= 0: + continue + market = (row.get("connector") or "", row.get("pair") or "") + if market not in prices: + try: + prices[market] = float(await fetch_current_price(client, *market) or 0) + except (TypeError, ValueError): + prices[market] = 0.0 + if prices[market] > 0: + row["amount"] = base * prices[market] + else: + unpriced.append(row.get("id") or "?") + return unpriced + + class ExecutorsProvider(BaseProvider): name = "executors" is_core = True @@ -139,6 +178,13 @@ async def execute( "concluding this session is break-even." ) + unpriced = await _price_pending_rows(client, running) + if unpriced: + lines.append( + f" ⚠️ No price for pending executor(s) {', '.join(unpriced)} — " + "their exposure is unknown, so new positions are refused until " + "they fill or a price is available." + ) total_exposure = sum(r.get("amount", 0) for r in running) return ProviderResult( @@ -153,6 +199,7 @@ async def execute( "total_volume": perf.volume, "total_fees": perf.fees, "total_exposure": total_exposure, + "unpriced_exposure": unpriced, "open_count": perf.open_count, "closed_count": perf.closed_count, "win_rate": perf.win_rate, diff --git a/condor/fetchers/executors.py b/condor/fetchers/executors.py index 11391628d..9cecbd614 100644 --- a/condor/fetchers/executors.py +++ b/condor/fetchers/executors.py @@ -158,6 +158,8 @@ def _quote_size( executor reports as filled, and otherwise to 0: it is never returned as-is. Summed as exposure, a base figure made 2.9 LTC read as $2.90, so a $0.70 loss on a ~$200 position read as a 24% drawdown and tripped the kill switch. + That 0 means "not priced here", not "no exposure": the agents' executors + provider, which has a client, prices such a row at the live price. """ total = float(cfg.get("total_amount_quote") or 0) if total > 0: diff --git a/tests/test_executor_row_amount_quote.py b/tests/test_executor_row_amount_quote.py index 84e659cce..0b92bf707 100644 --- a/tests/test_executor_row_amount_quote.py +++ b/tests/test_executor_row_amount_quote.py @@ -16,6 +16,7 @@ from condor.agents.journal import JournalManager from condor.agents.performance import AgentPerformance, _executor_row from condor.agents.providers.executors import ExecutorsProvider +from condor.agents.risk import RiskEngine, RiskLimits, RiskState from condor.fetchers.executors import build_executor_row @@ -114,3 +115,57 @@ async def _fake(client, agent_id, **_kw): ) assert journal.get_drawdown_pct() == pytest.approx(0.35, abs=0.01) + + +def _pending_market_order(): + """A market order still pending at the next tick: no price, no fill.""" + return { + "id": "hype-1", + "status": "RUNNING", + "config": { + "type": "order_executor", + "connector_name": "hyperliquid_perpetual", + "trading_pair": "HYPE-USD", + "side": "BUY", + "amount": 2.0, + }, + } + + +def _provider_data(monkeypatch, price): + async def _fake(client, agent_id, **_kw): + return AgentPerformance( + agent_id=agent_id, executors=[_executor_row(_pending_market_order())] + ) + + async def _price(client, connector_name="", trading_pair="", **_kw): + assert (connector_name, trading_pair) == ("hyperliquid_perpetual", "HYPE-USD") + return price + + monkeypatch.setattr("condor.agents.performance.fetch_agent_performance", _fake) + monkeypatch.setattr("condor.fetchers.market_data.fetch_current_price", _price) + return asyncio.run(ExecutorsProvider().execute(object(), {}, agent_id="a_1")).data + + +def test_pending_market_order_keeps_its_exposure_on_the_next_tick(monkeypatch): + """$150 cap, a $100 market order still pending: a further $60 is refused.""" + data = _provider_data(monkeypatch, price=50.0) + assert data["total_exposure"] == pytest.approx(100.0) + assert data["unpriced_exposure"] == [] + + state = RiskState(total_exposure=data["total_exposure"], executor_count=1) + allowed, reason = RiskEngine( + RiskLimits(max_position_size_quote=150.0) + ).check_executor_action( + {"tool": "create_order_executor", "input": {"amount": 1.2}}, state, 60.0 + ) + + assert not allowed + assert "position limit" in reason + + +def test_pending_order_with_no_price_is_reported_unpriced_not_flat(monkeypatch): + data = _provider_data(monkeypatch, price=None) + + assert data["total_exposure"] == 0.0 + assert data["unpriced_exposure"] == ["hype-1"]