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 5bd2a1960..9cecbd614 100644 --- a/condor/fetchers/executors.py +++ b/condor/fetchers/executors.py @@ -134,6 +134,53 @@ 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. + 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: + 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 +239,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..0b92bf707 --- /dev/null +++ b/tests/test_executor_row_amount_quote.py @@ -0,0 +1,171 @@ +"""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.agents.risk import RiskEngine, RiskLimits, RiskState +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) + + +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"]