diff --git a/scripts/ops/test_watch_live_mm.py b/scripts/ops/test_watch_live_mm.py index 63c0cb0..c526d60 100644 --- a/scripts/ops/test_watch_live_mm.py +++ b/scripts/ops/test_watch_live_mm.py @@ -22,6 +22,11 @@ ASSETS = { symbol="EURe", decimals=18, ), + "nep141:usdc.omft.near": watch_live_mm.AssetMeta( + asset_id="nep141:usdc.omft.near", + symbol="USDC", + decimals=6, + ), } @@ -99,6 +104,7 @@ class WatchLiveMmTests(unittest.TestCase): "spendable": { "nep141:btc.omft.near": "137014", "nep141:eure": "38999978799978799978", + "nep141:usdc.omft.near": "1234567", } } }, @@ -132,6 +138,38 @@ class WatchLiveMmTests(unittest.TestCase): self.assertEqual(state["mark_to_market_pnl_eure"], "0.497413887978799978") self.assertEqual(state["btc_spendable"], "0.00137014 BTC") self.assertEqual(state["eure_spendable"], "38.9999788 EURe") + self.assertIn("1.234567 USDC", state["spendable_assets"]) + + def test_current_state_fingerprint_does_not_require_eure_asset(self) -> None: + assets = { + key: value + for key, value in ASSETS.items() + if value.symbol != "EURe" + } + responses = { + "near-intents-ingest": {"ingest": {"published_count": 1}}, + "inventory-sync": { + "last_snapshot": { + "spendable": { + "nep141:btc.omft.near": "100000", + "nep141:usdc.omft.near": "5000000", + } + } + }, + "history-writer": {"latest_portfolio_metrics": {}}, + "strategy-engine": {"armed": False, "latest_decision": {}}, + "trade-executor": {"armed": False, "signer_registered": True}, + } + + with mock.patch.object( + watch_live_mm, + "fetch_service_state", + side_effect=lambda *, namespace, deployment: responses[deployment], + ): + state = watch_live_mm.current_state_fingerprint(namespace="unrip", assets=assets) + + self.assertEqual(state["eure_spendable"], "untracked") + self.assertIn("5 USDC", state["spendable_assets"]) if __name__ == "__main__": diff --git a/scripts/ops/watch_live_mm.py b/scripts/ops/watch_live_mm.py index 61ac70d..cc1d990 100644 --- a/scripts/ops/watch_live_mm.py +++ b/scripts/ops/watch_live_mm.py @@ -219,6 +219,7 @@ def current_state_fingerprint(*, namespace: str, assets: dict[str, AssetMeta]) - spendable = snapshot.get("spendable") or {} latest_decision = strategy.get("latest_decision") or {} latest_metrics = history.get("latest_portfolio_metrics") or {} + spendable_assets = format_spendable_assets(spendable=spendable, assets=assets) return { "ts": now_string(), @@ -227,8 +228,9 @@ def current_state_fingerprint(*, namespace: str, assets: dict[str, AssetMeta]) - "strategy_armed": str(bool(strategy.get("armed"))).lower(), "executor_armed": str(bool(executor.get("armed"))).lower(), "signer_registered": str(executor.get("signer_registered")), - "btc_spendable": format_amount(spendable.get(find_symbol_asset_id(assets, "BTC"), "0"), assets.get(find_symbol_asset_id(assets, "BTC"))), - "eure_spendable": format_amount(spendable.get(find_symbol_asset_id(assets, "EURe"), "0"), assets.get(find_symbol_asset_id(assets, "EURe"))), + "spendable_assets": ", ".join(spendable_assets) if spendable_assets else "none", + "btc_spendable": format_symbol_amount(spendable=spendable, assets=assets, symbol="BTC"), + "eure_spendable": format_symbol_amount(spendable=spendable, assets=assets, symbol="EURe"), "latest_reason": str(latest_decision.get("decision_reason") or "none"), "latest_quote_id": str(latest_decision.get("quote_id") or "none"), "trade_pnl_eure": str(latest_metrics.get("trade_pnl_eure") or "-"), @@ -293,6 +295,7 @@ def print_snapshot(*, namespace: str, assets: dict[str, AssetMeta]) -> None: f" signer_registered={state['signer_registered']}" f" btc={state['btc_spendable']}" f" eure={state['eure_spendable']}" + f" balances=[{state['spendable_assets']}]" f" trade_pnl_eure={state['trade_pnl_eure']}" f" mtm_pnl_eure={state['mark_to_market_pnl_eure']}" f" latest_reason={state['latest_reason']}" @@ -308,6 +311,7 @@ def print_state_line(state: dict[str, str]) -> None: f" signer_registered={state['signer_registered']}" f" btc={state['btc_spendable']}" f" eure={state['eure_spendable']}" + f" balances=[{state['spendable_assets']}]" f" trade_pnl_eure={state['trade_pnl_eure']}" f" mtm_pnl_eure={state['mark_to_market_pnl_eure']}" f" last_matching_quote_at={state['last_matching_quote_at']}" @@ -404,6 +408,22 @@ def find_symbol_asset_id(assets: dict[str, AssetMeta], symbol: str) -> str: raise KeyError(f"missing asset for symbol {symbol}") +def format_symbol_amount(*, spendable: dict[str, str], assets: dict[str, AssetMeta], symbol: str) -> str: + for asset_id, asset in assets.items(): + if asset.symbol == symbol: + return format_amount(spendable.get(asset_id, "0"), asset) + return "untracked" + + +def format_spendable_assets(*, spendable: dict[str, str], assets: dict[str, AssetMeta]) -> list[str]: + rows: list[str] = [] + for asset_id, asset in sorted(assets.items(), key=lambda entry: (entry[1].symbol, entry[0])): + rows.append(format_amount(spendable.get(asset_id, "0"), asset)) + for asset_id in sorted(set(spendable) - set(assets)): + rows.append(f"{spendable.get(asset_id, '0')} {asset_id}") + return rows + + def format_amount(raw_amount: str | None, asset: AssetMeta | None, *, max_places: int = 8) -> str: if raw_amount in (None, ""): return "-" diff --git a/src/apps/strategy-engine.mjs b/src/apps/strategy-engine.mjs index 23457c0..aebefa1 100644 --- a/src/apps/strategy-engine.mjs +++ b/src/apps/strategy-engine.mjs @@ -157,7 +157,7 @@ async function handleDemand(event) { source: 'strategy-engine', venue: 'near-intents', eventType: 'execute_trade', - observedAt: event.observed_at, + observedAt: evaluation.command.decision_at || event.observed_at || event.ingested_at, payload: evaluation.command, }); await producer.sendJson(config.kafkaTopicCmdExecuteTrade, commandEvent, { key: evaluation.command.execution_key }); diff --git a/src/core/intent-request-controller.mjs b/src/core/intent-request-controller.mjs index d2aef1f..8bee091 100644 --- a/src/core/intent-request-controller.mjs +++ b/src/core/intent-request-controller.mjs @@ -49,11 +49,11 @@ export function createIntentRequestController({ const destinationAsset = requestPair.destinationAsset; const sourceAmount = String( body.source_amount - || body.amount - || body.amount_eure - || requestPair.requestDefaultNotional - || config.intentRequestDefaultAmountEure - || '5', + ?? body.amount + ?? body.amount_eure + ?? requestPair.requestDefaultNotional + ?? config.intentRequestDefaultAmountEure + ?? '5', ); const legacyAmountEure = body.amount_eure == null ? null : String(body.amount_eure); const slippageBps = Number(body.slippage_bps ?? requestPair.slippageBps ?? config.intentRequestDefaultSlippageBps ?? 200); @@ -95,7 +95,12 @@ export function createIntentRequestController({ sourceAsset.decimals, { field: 'intent_request_max_notional' }, ); - sourceAmountUnits = parseDecimalToUnits(sourceAmount, sourceAsset.decimals, { field: 'source_amount' }); + try { + sourceAmountUnits = parseDecimalToUnits(sourceAmount, sourceAsset.decimals, { field: 'source_amount' }); + } catch (error) { + blockedBeforeQuote = true; + throw codedError('invalid_source_amount', error.message || 'Source amount is invalid.'); + } if (maxAmountUnits != null && BigInt(sourceAmountUnits) > BigInt(maxAmountUnits)) { blockedBeforeQuote = true; throw codedError( @@ -198,7 +203,10 @@ export function createIntentRequestController({ throw codedError('solver_quote_unanswered', 'The relay returned no solver quotes for this request.'); } if (!selectedQuote) { - throw codedError('quote_below_min_receive', 'Solver quotes were below the explicit minimum BTC receive amount.'); + throw codedError( + 'quote_below_min_receive', + `Solver quotes were below the explicit minimum ${destinationAsset.symbol || 'destination'} receive amount.`, + ); } state = 'draft'; diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index ffe764e..ce4e277 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -1001,6 +1001,8 @@ function normalizeIntentRequestForUi({ config, request }) { const destinationAsset = config.assetRegistry.get(request.destination_asset_id) || config.tradingBtc; return { ...request, + source_symbol: request.source_symbol || sourceAsset?.symbol || 'Source', + destination_symbol: request.destination_symbol || destinationAsset?.symbol || 'Destination', source_amount: formatUnits(request.source_amount_units || '0', sourceAsset?.decimals || 0), expected_destination_amount: formatUnits( request.expected_destination_amount_units || '0', diff --git a/src/core/strategy.mjs b/src/core/strategy.mjs index 19045f1..fa975bb 100644 --- a/src/core/strategy.mjs +++ b/src/core/strategy.mjs @@ -29,8 +29,10 @@ export function evaluateTradeOpportunity({ const effectiveMaxNotional = pairRuntime.maxNotional ?? maxNotional; const legacyEureNotional = isLegacyEureNotional({ pairRuntime, config }); const decisionId = crypto.randomUUID(); + const decisionAt = new Date(now).toISOString(); const baseDecision = { decision_id: decisionId, + decision_at: decisionAt, quote_id: payload.quote_id, pair: pairKey(payload.asset_in, payload.asset_out), pair_id: pairRuntime.pair?.pairId || null, @@ -143,11 +145,13 @@ export function evaluateTradeOpportunity({ pair_config_id: decision.pair_config_id, pair_config_version: decision.pair_config_version, edge_bps: decision.edge_bps, + decision_at: decision.decision_at, max_notional: decision.max_notional, min_notional: decision.min_notional, max_notional_eure: decision.max_notional_eure, price_route_id: decision.price_route_id, reference_price_id: buildResult.details.price_id || null, + direction: decision.direction, notional: decision.notional, notional_asset_id: decision.notional_asset_id, notional_symbol: decision.notional_symbol, diff --git a/src/operator-dashboard/static/pages/FundsPage.jsx b/src/operator-dashboard/static/pages/FundsPage.jsx index 0bddd4a..51457b1 100644 --- a/src/operator-dashboard/static/pages/FundsPage.jsx +++ b/src/operator-dashboard/static/pages/FundsPage.jsx @@ -382,6 +382,7 @@ function IntentRequestForm({ summary, onControl }) { } function IntentRequestLifecycle({ item }) { + const destinationSymbol = item.destination_symbol || 'destination'; return (