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
10 changes: 10 additions & 0 deletions condor/agents/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions condor/agents/providers/executors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +41 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Partial fills hide pending exposure

If a running market order is partially filled but has no reported entry, limit, or current price, its row uses the positive filled quote amount. This check then skips both live pricing and the unpriced warning. The position cap counts only the fill, not the full pending order, so a later create can be approved even when the two orders exceed the cap.

Knowledge Base Used: Agent execution and risk controls

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
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
51 changes: 48 additions & 3 deletions condor/fetchers/executors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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.

Expand Down Expand Up @@ -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 ""),
Expand Down
171 changes: 171 additions & 0 deletions tests/test_executor_row_amount_quote.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading