diff --git a/backend/config/README_CONFIG.md b/backend/config/README_CONFIG.md index eaad88b..5d4677a 100644 --- a/backend/config/README_CONFIG.md +++ b/backend/config/README_CONFIG.md @@ -29,6 +29,52 @@ ROMA Trading Platform supports **two configuration styles**: - **`QUICK_START.md`** - Step-by-step setup guide - **`README_CONFIG.md`** - This file (detailed documentation) +## Global System Settings + +```yaml +system: + scan_interval_minutes: 3 + max_concurrent_agents: 6 + log_level: "INFO" + +api: + host: "0.0.0.0" + port: 8000 + +x402: + enabled: false + price_usdc: 5.0 + network: ${X402_NETWORK:-base-sepolia} + pay_to_address: ${X402_PAY_TO_ADDRESS} + facilitator_url: ${X402_FACILITATOR_URL} + payment_description: "roma-01 strategy recommendation" + resource_description: "AI-generated trading strategy advice" + resource_mime_type: "application/json" + max_deadline_seconds: 120 + discoverable: true + cdp_api_key_id: ${X402_CDP_API_KEY_ID} + cdp_api_key_secret: ${X402_CDP_API_KEY_SECRET} + +remote_strategy: + enabled: false + endpoint: ${REMOTE_X402_ENDPOINT} + network: ${REMOTE_X402_NETWORK} + payment_asset: ${REMOTE_X402_PAYMENT_ASSET:-USDC} + account: ${REMOTE_X402_ACCOUNT} + private_key: ${REMOTE_X402_PRIVATE_KEY} + price_cap: ${REMOTE_X402_PRICE_CAP} + discovery: ${REMOTE_X402_DISCOVERY} + fallback_mode: ${REMOTE_FALLBACK_MODE:-local} + timeout_seconds: ${REMOTE_TIMEOUT_SECONDS:-10} + retry_limit: ${REMOTE_RETRY_LIMIT:-1} +``` + +- `x402.enabled`: Whether to enable the paid entry. Defaults to `false`. When enabled, you must also configure payout address, price, network, facilitator, and CDP API key. +- `remote_strategy.enabled`: Whether to act as a buyer and call a remote `/x402`. Defaults to `false`. When enabled, provide the remote endpoint and wallet private key (inject via env vars or secrets manager). +- `fallback_mode`: Strategy when the remote call fails: `local` = fall back to local model, `wait` = skip this cycle, `error` = raise immediately. +- `price_cap`: Optional cap on the maximum payment per request (USDC). +- Default settings read values from environment variables to avoid hardcoding; leave unused fields blank. + ## Configuration Styles ### 1. Legacy Style (Backward Compatible) diff --git a/backend/config/trading_config.yaml b/backend/config/trading_config.yaml new file mode 100644 index 0000000..5c87883 --- /dev/null +++ b/backend/config/trading_config.yaml @@ -0,0 +1,213 @@ +# ROMA Trading Platform - Main Configuration +# Account-Centric Configuration Style +# +# This configuration uses the account-centric architecture: +# - accounts: Define all DEX accounts (Aster, Hyperliquid, etc.) +# - models: Define all LLM models (DeepSeek, Qwen, Claude, etc.) +# - agents: Bind accounts with models to create trading agents +# +# You can add more accounts, models, and agents as needed. +# See README_CONFIG.md for detailed documentation. + +system: + scan_interval_minutes: 3 + max_concurrent_agents: 6 + log_level: "INFO" + prompt_language: "en" # zh(中文) or en(English) + +auth: + admin: + username: "admin" + password_hash: "pbkdf2_sha256$600000$gxtnJqmRLdRxef4d6bljzA==$ZwvN+9xZAJroC1BFdop9TgJEkL3ok+76+e/RHAdU0fI=" + updated_at: "2025-11-10T00:00:00Z" + +api: + host: "0.0.0.0" + port: 8080 + +x402: + enabled: false + price_usdc: 5.0 + network: ${X402_NETWORK:-base-sepolia} + pay_to_address: ${X402_PAY_TO_ADDRESS} + facilitator_url: ${X402_FACILITATOR_URL} + payment_description: "roma-01 strategy recommendation" + resource_description: "AI-generated trading strategy advice" + resource_mime_type: "application/json" + max_deadline_seconds: 120 + discoverable: true + cdp_api_key_id: ${X402_CDP_API_KEY_ID} + cdp_api_key_secret: ${X402_CDP_API_KEY_SECRET} + +remote_strategy: + enabled: false + endpoint: ${REMOTE_X402_ENDPOINT} + network: ${REMOTE_X402_NETWORK} + payment_asset: ${REMOTE_X402_PAYMENT_ASSET:-USDC} + account: ${REMOTE_X402_ACCOUNT} + private_key: ${REMOTE_X402_PRIVATE_KEY} + price_cap: ${REMOTE_X402_PRICE_CAP} + discovery: ${REMOTE_X402_DISCOVERY} + fallback_mode: ${REMOTE_FALLBACK_MODE:-local} + timeout_seconds: ${REMOTE_TIMEOUT_SECONDS:-10} + retry_limit: ${REMOTE_RETRY_LIMIT:-1} + +# ============================================ +# Accounts: Define your DEX trading accounts +# ============================================ +accounts: + # Default Aster DEX account + - id: "aster-acc-01" + name: "Aster Account 01" + dex_type: "aster" + user: ${ASTER_USER_01} + signer: ${ASTER_SIGNER_01} + private_key: ${ASTER_PRIVATE_KEY_01} + testnet: false + hedge_mode: false + + # Add more accounts as needed: + # - id: "aster-acc-02" + # name: "Aster Account 02" + # dex_type: "aster" + # user: ${ASTER_USER_02} + # signer: ${ASTER_SIGNER_02} + # private_key: ${ASTER_PRIVATE_KEY_02} + # testnet: false + # hedge_mode: false + # + # - id: "hl-acc-01" + # name: "Hyperliquid Account 01" + # dex_type: "hyperliquid" + # api_secret: ${HL_SECRET_KEY_01} + # account_id: ${HL_ACCOUNT_ADDRESS_01} # Main wallet address + # testnet: false + # hedge_mode: false + +# ============================================ +# Models: Define your LLM models +# ============================================ +models: + # DeepSeek Chat V3.1 + - id: "deepseek-v3.1" + provider: "deepseek" + api_key: ${DEEPSEEK_API_KEY} + model: "deepseek-chat" + temperature: 0.15 + max_tokens: 4000 + + # # Qwen3 Max + # - id: "qwen3-max" + # provider: "qwen" + # api_key: ${QWEN_API_KEY} + # model: "qwen-max" + # location: "china" # "china" for China region, "international" or other values for international region + # temperature: 0.15 + # max_tokens: 4000 + + # # Claude Sonnet 4.5 + # - id: "claude-sonnet-4.5" + # provider: "anthropic" + # api_key: ${ANTHROPIC_API_KEY} + # model: "claude-sonnet-4.5" + # temperature: 0.15 + # max_tokens: 4000 + + # # GPT-5 + # - id: "gpt-5" + # provider: "openai" + # api_key: ${OPENAI_API_KEY} + # model: "gpt-5" + # temperature: 0.15 + # max_tokens: 4000 + + # # Grok 4 + # - id: "grok-4" + # provider: "xai" + # api_key: ${XAI_API_KEY} + # model: "grok-4" + # temperature: 0.15 + # max_tokens: 4000 + + # # Gemini 2.5 Pro + # - id: "gemini-2.5-pro" + # provider: "google" + # api_key: ${GOOGLE_API_KEY} + # model: "gemini-2.5-pro" + # temperature: 0.15 + # max_tokens: 4000 + +# ============================================ +# Agents: Bind accounts with models +# ============================================ +agents: + # Default agent: DeepSeek on Aster Account + - id: "deepseek-aster-01" + name: "DeepSeek on Aster-01" + enabled: true + account_id: "aster-acc-01" # Reference to account above + model_id: "deepseek-v3.1" # Reference to model above + strategy: + initial_balance: 10000.0 + scan_interval_minutes: 3 + max_account_usage_pct: 100 + default_coins: + - BTCUSDT + - ETHUSDT + - SOLUSDT + - BNBUSDT + - DOGEUSDT + - XRPUSDT + risk_management: + max_positions: 3 + max_leverage: 10 + max_position_size_pct: 30 + max_total_position_pct: 80 + max_single_trade_pct: 50 + max_single_trade_with_positions_pct: 30 + max_daily_loss_pct: 15 + stop_loss_pct: 3 + take_profit_pct: 10 + advanced_orders: + enable_take_profit: false + take_profit_pct: 5.0 + enable_stop_loss: false + stop_loss_pct: 2.0 + trading_style: "balanced" + custom_prompts: + enabled: false + trading_philosophy: "" + entry_preferences: "" + position_management: "" + market_preferences: "" + additional_rules: "" + + # Add more agents as needed: + # Example: Same model on different account + # - id: "deepseek-aster-02" + # name: "DeepSeek on Aster-02" + # enabled: false + # account_id: "aster-acc-02" + # model_id: "deepseek-v3.1" # Same model + # strategy: + # initial_balance: 10000.0 + # scan_interval_minutes: 3 + # default_coins: [BTCUSDT, ETHUSDT] + # risk_management: + # max_positions: 3 + # max_leverage: 10 + # + # Example: Different model on same account + # - id: "qwen-aster-01" + # name: "Qwen on Aster-01" + # enabled: false + # account_id: "aster-acc-01" # Same account + # model_id: "qwen3-max" # Different model + # strategy: + # initial_balance: 10000.0 + # scan_interval_minutes: 3 + # default_coins: [BTCUSDT, ETHUSDT] + # risk_management: + # max_positions: 3 + # max_leverage: 10 + diff --git a/backend/config/trading_config.yaml.example b/backend/config/trading_config.yaml.example index fb204da..3bbd434 100644 --- a/backend/config/trading_config.yaml.example +++ b/backend/config/trading_config.yaml.example @@ -35,6 +35,39 @@ api: host: "0.0.0.0" port: 8080 +# ============================================ +# x402 Server Configuration (Seller mode) +# ============================================ +x402: + enabled: false # Set to true to enable /x402 paid endpoint + price_usdc: 5.0 # Fee per request (USDC) + network: ${X402_NETWORK:-base-sepolia} + pay_to_address: ${X402_PAY_TO_ADDRESS} + facilitator_url: ${X402_FACILITATOR_URL} # Optional custom facilitator URL + payment_description: "roma-01 strategy recommendation" + resource_description: "AI-generated trading strategy advice" + resource_mime_type: "application/json" + max_deadline_seconds: 120 + discoverable: true + cdp_api_key_id: ${X402_CDP_API_KEY_ID} # Required when using Coinbase facilitator + cdp_api_key_secret: ${X402_CDP_API_KEY_SECRET} + +# ============================================ +# Remote Strategy Configuration (Buyer mode) +# ============================================ +remote_strategy: + enabled: false # Set to true to call remote /x402 services + endpoint: ${REMOTE_X402_ENDPOINT} # e.g. https://partner.example.com/x402 + network: ${REMOTE_X402_NETWORK} # Optional network filter + payment_asset: ${REMOTE_X402_PAYMENT_ASSET:-USDC} + account: ${REMOTE_X402_ACCOUNT} # Buyer wallet address + private_key: ${REMOTE_X402_PRIVATE_KEY} # Buyer wallet private key (use secure storage) + price_cap: ${REMOTE_X402_PRICE_CAP} # Optional max fee per request + discovery: ${REMOTE_X402_DISCOVERY} # Optional facilitator discovery endpoint + fallback_mode: ${REMOTE_FALLBACK_MODE:-local} # local|wait|error + timeout_seconds: ${REMOTE_TIMEOUT_SECONDS:-10} + retry_limit: ${REMOTE_RETRY_LIMIT:-1} + # ============================================ # Accounts: Define your DEX trading accounts # ============================================ diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4145907..7de6e30 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "eth-abi>=5.1.0", "web3>=7.0.0", "hyperliquid-python-sdk>=0.20.0", + "x402>=0.2.1", "ruamel.yaml>=0.18.6", ] diff --git a/backend/src/roma_trading/agents/trading_agent.py b/backend/src/roma_trading/agents/trading_agent.py index 642f8ce..e3bc220 100644 --- a/backend/src/roma_trading/agents/trading_agent.py +++ b/backend/src/roma_trading/agents/trading_agent.py @@ -16,7 +16,16 @@ from loguru import logger from roma_trading.toolkits import AsterToolkit, TechnicalAnalysisToolkit -from roma_trading.core import DecisionLogger, PerformanceAnalyzer +from roma_trading.config import get_settings +from roma_trading.core import ( + DecisionLogger, + PerformanceAnalyzer, + build_strategy_request, + get_remote_strategy_client, + RemoteStrategyError, + RemoteStrategyPaymentError, + RemoteStrategyConfigError, +) from roma_trading.prompts import render_prompt from roma_trading.services.trade_execution_service import TradeExecutionService from roma_trading.services.llm_client_factory import LLMClientFactory @@ -189,6 +198,22 @@ async def trading_cycle(self): # 2. Fetch account and positions account = await self.dex.get_account_balance() positions = await self.dex.get_positions() + + settings = get_settings() + + if settings.remote_strategy_enabled: + remote_handled = await self._handle_remote_strategy( + settings=settings, + account=account, + positions=positions, + ) + if remote_handled: + logger.info( + "Remote strategy fulfilled for agent %s cycle %s; skipping local execution", + self.agent_id, + self.cycle_count, + ) + return # Calculate this agent's budget max_usage_pct = self.config["strategy"].get("max_account_usage_pct", 100) @@ -251,6 +276,81 @@ async def trading_cycle(self): logger.info(f"Cycle #{self.cycle_count} complete\n") + async def _handle_remote_strategy(self, settings, account: Dict, positions: List[Dict]) -> bool: + """Attempt to fetch remote strategy; return True if handled and local cycle should stop.""" + + try: + remote_client = await get_remote_strategy_client() + except RemoteStrategyConfigError as exc: + logger.error("Remote strategy disabled due to configuration error: {}", exc) + return False + + strategy_cfg = self.config.get("strategy", {}) + exchange_cfg = self.config.get("exchange", {}) + + try: + request_payload = build_strategy_request( + agent_id=self.agent_id, + exchange_cfg=exchange_cfg, + strategy_cfg=strategy_cfg, + account_snapshot=account, + positions=positions, + cycle=self.cycle_count, + ) + except Exception as exc: # pragma: no cover - defensive + logger.error("Failed to construct remote strategy request: {}", exc) + fallback_mode = (settings.remote_fallback_mode or "local").lower() + return fallback_mode != "local" + + try: + result = await remote_client.request_strategy(request_payload) + except RemoteStrategyPaymentError as exc: + logger.error("Remote strategy payment failed: {}", exc) + fallback_mode = (settings.remote_fallback_mode or "local").lower() + if fallback_mode == "error": + raise + if fallback_mode == "wait": + return True + return False + except RemoteStrategyError as exc: + logger.error("Remote strategy request failed: {}", exc) + fallback_mode = (settings.remote_fallback_mode or "local").lower() + if fallback_mode == "error": + raise + if fallback_mode == "wait": + return True + return False + except Exception as exc: # pragma: no cover - unexpected + logger.exception("Unexpected error while requesting remote strategy: {}", exc) + fallback_mode = (settings.remote_fallback_mode or "local").lower() + if fallback_mode == "error": + raise + if fallback_mode == "wait": + return True + return False + + self.logger_module.log_remote_strategy( + cycle=self.cycle_count, + account=account, + positions=positions, + payload=result.to_logging_payload(), + ) + + strategy = result.response.strategy + logger.info("Remote strategy summary: %s", strategy.summary) + if strategy.steps: + for idx, step in enumerate(strategy.steps, start=1): + logger.info("Remote step %d: %s", idx, step) + + fallback_mode = (settings.remote_fallback_mode or "local").lower() + if fallback_mode == "local": + # After successful remote strategy we simply skip local execution. + return True + if fallback_mode == "wait": + return True + + return True + async def _fetch_market_data(self, positions: List[Dict]) -> Dict: """Fetch market data for relevant symbols.""" symbols = self.config["strategy"]["default_coins"] diff --git a/backend/src/roma_trading/api/main.py b/backend/src/roma_trading/api/main.py index 3e4fa3c..90dbaec 100644 --- a/backend/src/roma_trading/api/main.py +++ b/backend/src/roma_trading/api/main.py @@ -32,6 +32,15 @@ from roma_trading.core.chat_service import initialize_chat_service, get_chat_service from roma_trading.core.trade_history_analyzer import TradeHistoryAnalyzer from roma_trading.core.analysis_scheduler import AnalysisScheduler +from roma_trading.api.routes.x402 import router as x402_router + +try: # pragma: no cover - import guard for optional x402 dependency + from x402.fastapi.middleware import require_payment + from x402.types import HTTPInputSchema +except ImportError as exc: # pragma: no cover - handled at runtime + require_payment = None # type: ignore + HTTPInputSchema = None # type: ignore + logger.warning("x402 package not installed: {}", exc) from roma_trading.api.routes import config as config_routes from roma_trading.prompts import initialize_prompt_repository from roma_trading.services import ServiceManager @@ -214,6 +223,83 @@ async def _on_large_trade_appended(record) -> None: app.include_router(dashboard_routes.router) +def configure_x402_payment(app: FastAPI): + """Attach x402 middleware when enabled and properly configured.""" + + if require_payment is None or HTTPInputSchema is None: + logger.info("x402 Python SDK unavailable; skipping paid endpoint configuration") + return + + if not settings.x402_enabled: + logger.info("x402 payments disabled via configuration") + return + + if not settings.x402_pay_to_address: + logger.warning("x402 enabled but X402_PAY_TO_ADDRESS not set; skipping middleware") + return + + facilitator_config = None + + if settings.x402_cdp_api_key_id and settings.x402_cdp_api_key_secret: + try: + from cdp.x402 import create_facilitator_config # type: ignore + + facilitator_config = create_facilitator_config( + settings.x402_cdp_api_key_id, + settings.x402_cdp_api_key_secret, + ) + if settings.x402_facilitator_url: + facilitator_config["url"] = settings.x402_facilitator_url + except ImportError as exc: + logger.error( + "CDP facilitator requested but cdp-sdk is not installed: {}. Install 'cdp-sdk' to enable authenticated facilitator calls.", + exc, + ) + except Exception as exc: # pragma: no cover - runtime safety + logger.error("Failed to initialize CDP facilitator config: {}", exc) + + if facilitator_config is None and settings.x402_facilitator_url: + facilitator_config = {"url": settings.x402_facilitator_url} + + input_schema = HTTPInputSchema( + body_type="json", + body_fields={ + "account": { + "platform": "hyperliquid|aster", + "positions": "list of positions with symbol, size, side, entryPx, markPx", + }, + "preferences": { + "leverage": "Optional leverage preference", + "riskTolerance": "Optional textual risk tolerance", + }, + }, + header_fields={"X-PAYMENT": "Base64 encoded x402 payment payload"}, + ) + + price_value = settings.x402_price_usdc + if isinstance(price_value, (int, float)): + price_value = str(price_value) + + app.middleware("http")( + require_payment( + price=price_value, + pay_to_address=settings.x402_pay_to_address, + path="/x402", + description=settings.x402_payment_description, + mime_type=settings.x402_resource_mime_type, + max_deadline_seconds=settings.x402_max_deadline_seconds, + input_schema=input_schema, + discoverable=settings.x402_discoverable, + facilitator_config=facilitator_config, + network=settings.x402_network, + ) + ) + + +configure_x402_payment(app) +app.include_router(x402_router) + + @app.get("/") async def root(): """API root endpoint.""" diff --git a/backend/src/roma_trading/api/routes/x402.py b/backend/src/roma_trading/api/routes/x402.py new file mode 100644 index 0000000..481e282 --- /dev/null +++ b/backend/src/roma_trading/api/routes/x402.py @@ -0,0 +1,96 @@ +"""FastAPI route for x402-protected strategy advisory endpoint.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal, InvalidOperation +from typing import Optional +from uuid import uuid4 + +from fastapi import APIRouter, HTTPException, Request +from loguru import logger + +from roma_trading.api.schemas.x402 import ( + PaymentReceipt, + StrategyMetadata, + StrategyRequest, + StrategyResponse, +) +from roma_trading.config import get_settings +from roma_trading.core import StrategyAdvisor + + +router = APIRouter(tags=["x402"]) + +_settings = get_settings() +_advisor = StrategyAdvisor() + + +def _atomic_amount_to_decimal(value: str, decimals: Optional[int]) -> str: + try: + decimals = int(decimals) if decimals is not None else 6 + except (TypeError, ValueError): # pragma: no cover - invalid config safety + decimals = 6 + + try: + quantized = Decimal(value) / (Decimal(10) ** Decimal(decimals)) + # Normalise to avoid excessive trailing zeros + return format(quantized.normalize(), "f") + except (InvalidOperation, ValueError): # pragma: no cover + logger.warning( + "Failed to convert atomic amount '{}' with decimals {}", value, decimals + ) + return value + + +@router.post("/x402", response_model=StrategyResponse) +async def create_strategy(request: Request, payload: StrategyRequest) -> StrategyResponse: + """Return a strategy recommendation once payment has been verified.""" + + payment_requirements = getattr(request.state, "payment_details", None) + verify_response = getattr(request.state, "verify_response", None) + + if payment_requirements is None or verify_response is None: + logger.debug("Payment validation did not occur before /x402 handler") + raise HTTPException(status_code=402, detail="Payment required") + + if not verify_response.is_valid: + raise HTTPException(status_code=402, detail="Invalid payment") + + recommendation = await _advisor.generate(payload) + + decimals_hint = None + extra = getattr(payment_requirements, "extra", None) or {} + if isinstance(extra, dict): + decimals_hint = extra.get("decimals") or extra.get("DECIMALS") + + amount_decimal = _atomic_amount_to_decimal(payment_requirements.max_amount_required, decimals_hint) + + disclaimer_text = _settings.strategy_disclaimer_text or "" + request_id = str(uuid4()) + + payment_receipt = PaymentReceipt( + amount=amount_decimal, + asset=payment_requirements.asset, + network=payment_requirements.network, + pay_to=payment_requirements.pay_to, + deadline_seconds=payment_requirements.max_timeout_seconds, + payer=verify_response.payer, + receipt_header=request.headers.get("X-PAYMENT"), + ) + + metadata = StrategyMetadata( + request_id=request_id, + model=_settings.strategy_model_id, + generated_at=datetime.now(tz=timezone.utc), + telemetry=payload.telemetry, + ) + + return StrategyResponse( + strategy=recommendation, + disclaimer=disclaimer_text, + payment=payment_receipt, + metadata=metadata, + ) + + diff --git a/backend/src/roma_trading/api/schemas/x402.py b/backend/src/roma_trading/api/schemas/x402.py new file mode 100644 index 0000000..a2d6cc1 --- /dev/null +++ b/backend/src/roma_trading/api/schemas/x402.py @@ -0,0 +1,109 @@ +"""Pydantic schemas for the x402 strategy advisory endpoint.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Literal, Optional + +from pydantic import AliasChoices, BaseModel, ConfigDict, Field + + +class BalanceSnapshot(BaseModel): + asset: str + amount: float + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + +class PositionSnapshot(BaseModel): + symbol: str + size: float = Field(validation_alias=AliasChoices("size", "quantity", "positionSize")) + side: Optional[str] = None + entry_price: Optional[float] = Field( + default=None, + validation_alias=AliasChoices("entry_price", "entryPx", "entry_price_usd"), + serialization_alias="entryPrice", + ) + mark_price: Optional[float] = Field( + default=None, + validation_alias=AliasChoices("mark_price", "markPx", "mark_price_usd"), + serialization_alias="markPrice", + ) + leverage: Optional[float] = None + unrealized_pnl: Optional[float] = Field(default=None, validation_alias=AliasChoices("unrealized_pnl", "unrealizedPnl")) + metadata: Dict[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + +class AccountSnapshot(BaseModel): + platform: Literal["hyperliquid", "aster"] + owner: Optional[str] = None + balance: Optional[BalanceSnapshot] = None + equity: Optional[float] = Field(default=None, validation_alias=AliasChoices("equity", "totalEquity")) + positions: List[PositionSnapshot] = Field(default_factory=list) + raw: Dict[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + +class PreferenceSnapshot(BaseModel): + leverage: Optional[float] = None + risk_tolerance: Optional[str] = Field(default=None, validation_alias=AliasChoices("riskTolerance", "risk_tolerance")) + time_horizon: Optional[str] = Field(default=None, validation_alias=AliasChoices("timeHorizon", "time_horizon")) + notes: Optional[str] = None + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + +class StrategyRequest(BaseModel): + account: AccountSnapshot + preferences: Optional[PreferenceSnapshot] = None + objectives: Optional[str] = None + constraints: Optional[List[str]] = None + telemetry: Dict[str, Any] = Field(default_factory=dict) + symbols: Optional[List[str]] = None + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + +class StrategyRecommendation(BaseModel): + summary: str + steps: List[str] + risk_notes: List[str] = Field(default_factory=list) + confidence: Optional[float] = None + rationale: Optional[str] = None + + model_config = ConfigDict(populate_by_name=True) + + +class PaymentReceipt(BaseModel): + amount: str + asset: str + network: str + pay_to: str + deadline_seconds: int + payer: Optional[str] = None + receipt_header: Optional[str] = None + + model_config = ConfigDict(populate_by_name=True) + + +class StrategyMetadata(BaseModel): + request_id: str + model: Optional[str] = None + generated_at: datetime + telemetry: Dict[str, Any] = Field(default_factory=dict) + + model_config = ConfigDict(populate_by_name=True) + + +class StrategyResponse(BaseModel): + strategy: StrategyRecommendation + disclaimer: str + payment: PaymentReceipt + metadata: StrategyMetadata + + model_config = ConfigDict(populate_by_name=True) + + diff --git a/backend/src/roma_trading/config/settings.py b/backend/src/roma_trading/config/settings.py index 86f6175..bde94b7 100644 --- a/backend/src/roma_trading/config/settings.py +++ b/backend/src/roma_trading/config/settings.py @@ -83,6 +83,38 @@ class Settings(BaseSettings): api_port: int = 8080 cors_origins: str = "http://localhost:3000" + # x402 Integration + x402_enabled: bool = False + x402_price_usdc: float = 5.0 + x402_network: str = "base-sepolia" + x402_pay_to_address: Optional[str] = None + x402_payment_description: str = "roma-01 strategy recommendation" + x402_resource_description: str = "AI-generated trading strategy advice" + x402_resource_mime_type: str = "application/json" + x402_max_deadline_seconds: int = 120 + x402_discoverable: bool = True + x402_facilitator_url: Optional[str] = None + x402_cdp_api_key_id: Optional[str] = None + x402_cdp_api_key_secret: Optional[str] = None + + # Strategy advisory configuration + strategy_model_id: Optional[str] = None + strategy_disclaimer_text: Optional[str] = ( + "These AI-generated trading suggestions are provided for informational purposes only and do not constitute financial advice." + ) + + # Remote strategy (Buyer) configuration + remote_strategy_enabled: bool = False + remote_x402_endpoint: Optional[str] = None + remote_x402_network: Optional[str] = None + remote_x402_payment_asset: str = "USDC" + remote_x402_account: Optional[str] = None + remote_x402_private_key: Optional[str] = None + remote_x402_price_cap: Optional[float] = None + remote_x402_discovery: Optional[str] = None + remote_fallback_mode: str = "local" + remote_timeout_seconds: int = 10 + remote_retry_limit: int = 1 # Config Portal Auth config_auth_secret: str = "roma-config-secret" config_token_exp_minutes: int = 120 diff --git a/backend/src/roma_trading/core/__init__.py b/backend/src/roma_trading/core/__init__.py index cb86300..a5a2d6e 100644 --- a/backend/src/roma_trading/core/__init__.py +++ b/backend/src/roma_trading/core/__init__.py @@ -2,6 +2,27 @@ from .decision_logger import DecisionLogger from .performance import PerformanceAnalyzer +from .strategy_advisor import StrategyAdvisor +from .remote_strategy_client import ( + RemoteStrategyClient, + RemoteStrategyError, + RemoteStrategyPaymentError, + RemoteStrategyConfigError, + RemoteStrategyResult, + build_strategy_request, + get_remote_strategy_client, +) -__all__ = ["DecisionLogger", "PerformanceAnalyzer"] +__all__ = [ + "DecisionLogger", + "PerformanceAnalyzer", + "StrategyAdvisor", + "RemoteStrategyClient", + "RemoteStrategyError", + "RemoteStrategyPaymentError", + "RemoteStrategyConfigError", + "RemoteStrategyResult", + "build_strategy_request", + "get_remote_strategy_client", +] diff --git a/backend/src/roma_trading/core/chat_service.py b/backend/src/roma_trading/core/chat_service.py index a9e7830..c95c0c8 100644 --- a/backend/src/roma_trading/core/chat_service.py +++ b/backend/src/roma_trading/core/chat_service.py @@ -7,13 +7,16 @@ import asyncio import dspy -from typing import Optional +from typing import Optional, TYPE_CHECKING from loguru import logger from roma_trading.config import get_settings from roma_trading.agents import AgentManager from roma_trading.prompts import render_prompt from roma_trading.core.token_analysis_handler import TokenAnalysisHandler +if TYPE_CHECKING: + pass # Keep for future type-only imports + class ChatResponse(dspy.Signature): """AI assistant response signature for chat.""" @@ -25,7 +28,7 @@ class ChatResponse(dspy.Signature): class ChatService: """Service for handling chat requests with AI assistant.""" - def __init__(self, agent_manager: AgentManager): + def __init__(self, agent_manager: "AgentManager"): self.agent_manager = agent_manager self.token_handler = TokenAnalysisHandler(agent_manager) @@ -193,7 +196,11 @@ def _get_llm(self): return lm raise RuntimeError("No LLM available for chat. Please ensure at least one agent is configured.") - + + def get_llm(self): + """Expose LLM construction for other services (e.g., strategy advisor).""" + return self._get_llm() + async def chat(self, message: str, language: str = "en") -> str: """ Process a chat message and return AI response. @@ -330,7 +337,7 @@ def get_chat_service() -> ChatService: return chat_service -def initialize_chat_service(agent_manager: AgentManager): +def initialize_chat_service(agent_manager: "AgentManager"): """Initialize global chat service.""" global chat_service chat_service = ChatService(agent_manager) diff --git a/backend/src/roma_trading/core/decision_logger.py b/backend/src/roma_trading/core/decision_logger.py index d04885d..11f0335 100644 --- a/backend/src/roma_trading/core/decision_logger.py +++ b/backend/src/roma_trading/core/decision_logger.py @@ -208,7 +208,114 @@ def log_decision( self._last_external_cash_flow = external_cash_flow logger.info(f"Logged decision cycle {cycle} for agent {self.agent_id}") - + + def log_remote_strategy( + self, + cycle: int, + account: Dict, + positions: List[Dict], + payload: Dict, + ) -> None: + """Log remote strategy suggestion response.""" + timestamp = datetime.now() + + current_equity = float(account.get("total_wallet_balance", 0.0)) + current_unrealized = float(account.get("total_unrealized_profit", 0.0)) + adjusted_equity = current_equity - self._net_deposits + + # Log as a decision with remote strategy info + import asyncio + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + asyncio.create_task( + self.decision_log_storage.create_decision_log( + agent_id=self.agent_id, + cycle_number=cycle, + timestamp=timestamp, + chain_of_thought=f"Remote strategy: {json.dumps(payload)}", + decisions=[{"source": "remote", "payload": payload}], + account_state=account, + positions=positions, + ) + ) + else: + loop.run_until_complete( + self.decision_log_storage.create_decision_log( + agent_id=self.agent_id, + cycle_number=cycle, + timestamp=timestamp, + chain_of_thought=f"Remote strategy: {json.dumps(payload)}", + decisions=[{"source": "remote", "payload": payload}], + account_state=account, + positions=positions, + ) + ) + except RuntimeError: + asyncio.run( + self.decision_log_storage.create_decision_log( + agent_id=self.agent_id, + cycle_number=cycle, + timestamp=timestamp, + chain_of_thought=f"Remote strategy: {json.dumps(payload)}", + decisions=[{"source": "remote", "payload": payload}], + account_state=account, + positions=positions, + ) + ) + + # Save equity history entry + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + asyncio.create_task( + self.equity_storage.create_equity_entry( + agent_id=self.agent_id, + timestamp=timestamp, + cycle=cycle, + equity=adjusted_equity, + adjusted_equity=adjusted_equity, + gross_equity=current_equity, + unrealized_pnl=current_unrealized, + pnl=current_unrealized, + net_deposits=self._net_deposits, + external_cash_flow=0.0, + ) + ) + else: + loop.run_until_complete( + self.equity_storage.create_equity_entry( + agent_id=self.agent_id, + timestamp=timestamp, + cycle=cycle, + equity=adjusted_equity, + adjusted_equity=adjusted_equity, + gross_equity=current_equity, + unrealized_pnl=current_unrealized, + pnl=current_unrealized, + net_deposits=self._net_deposits, + external_cash_flow=0.0, + ) + ) + except RuntimeError: + asyncio.run( + self.equity_storage.create_equity_entry( + agent_id=self.agent_id, + timestamp=timestamp, + cycle=cycle, + equity=adjusted_equity, + adjusted_equity=adjusted_equity, + gross_equity=current_equity, + unrealized_pnl=current_unrealized, + pnl=current_unrealized, + net_deposits=self._net_deposits, + external_cash_flow=0.0, + ) + ) + + logger.info(f"Logged remote strategy cycle {cycle} for agent {self.agent_id}") + + def record_open_position( self, symbol: str, diff --git a/backend/src/roma_trading/core/remote_strategy_client.py b/backend/src/roma_trading/core/remote_strategy_client.py new file mode 100644 index 0000000..0727284 --- /dev/null +++ b/backend/src/roma_trading/core/remote_strategy_client.py @@ -0,0 +1,274 @@ +"""Client utilities for requesting remote strategies over x402.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from decimal import Decimal +from typing import Dict, List, Optional, Tuple +from urllib.parse import urlsplit, urlunsplit + +import httpx +from eth_account import Account +from loguru import logger + +from x402.clients.base import PaymentError, PaymentAmountExceededError +from x402.clients.httpx import x402HttpxClient + +from roma_trading.api.schemas.x402 import ( + AccountSnapshot, + BalanceSnapshot, + PositionSnapshot, + PreferenceSnapshot, + StrategyRequest, + StrategyResponse, +) +from roma_trading.config import get_settings + + +ASSET_DECIMALS = { + "USDC": 6, + "USDT": 6, + "DAI": 18, +} + + +class RemoteStrategyError(Exception): + """Base exception for remote strategy operations.""" + + +class RemoteStrategyConfigError(RemoteStrategyError): + """Raised when remote strategy configuration is invalid.""" + + +class RemoteStrategyPaymentError(RemoteStrategyError): + """Raised when payment processing fails.""" + + +@dataclass +class RemoteStrategyResult: + """Result container for remote strategy responses.""" + + request: StrategyRequest + response: StrategyResponse + settlement_header: Optional[str] = None + + def to_logging_payload(self) -> Dict: + return { + "request": self.request.model_dump(by_alias=True), + "response": self.response.model_dump(by_alias=True), + "settlementHeader": self.settlement_header, + } + + +class RemoteStrategyClient: + """Client for interacting with remote /x402 strategy endpoints.""" + + def __init__(self): + settings = get_settings() + + if not settings.remote_x402_endpoint: + raise RemoteStrategyConfigError("Remote x402 endpoint not configured") + if not settings.remote_x402_private_key: + raise RemoteStrategyConfigError("Remote x402 private key not configured") + + self.settings = settings + + parts = urlsplit(settings.remote_x402_endpoint) + if not parts.scheme or not parts.netloc: + raise RemoteStrategyConfigError("Invalid remote x402 endpoint URL") + + self._base_url = urlunsplit((parts.scheme, parts.netloc, "", "", "")) + path = parts.path or "/x402" + if parts.query: + path = f"{path}?{parts.query}" + self._endpoint_path = path + + try: + self._account = Account.from_key(settings.remote_x402_private_key) + except Exception as exc: # pragma: no cover - invalid key runtime + raise RemoteStrategyConfigError("Failed to load remote buyer private key") from exc + + self._network_filter = settings.remote_x402_network + max_value = self._compute_max_value(settings.remote_x402_price_cap, settings.remote_x402_payment_asset) + self._max_value = max_value + + self._client_args = { + "max_value": max_value, + "payment_requirements_selector": self._select_payment_requirements, + "timeout": settings.remote_timeout_seconds, + } + + @staticmethod + def _compute_max_value(price_cap: Optional[float], asset: Optional[str]) -> Optional[int]: + if price_cap is None: + return None + asset_key = (asset or "USDC").upper() + decimals = ASSET_DECIMALS.get(asset_key, 6) + scaled = Decimal(str(price_cap)) * (Decimal(10) ** decimals) + return int(scaled) + + def _select_payment_requirements(self, accepts, network_filter, scheme_filter, max_value): + target_network = self._network_filter or network_filter + from x402.clients.base import x402Client + + return x402Client.default_payment_requirements_selector( + accepts, + network_filter=target_network, + scheme_filter=scheme_filter, + max_value=max_value or self._max_value, + ) + + async def request_strategy(self, payload: StrategyRequest) -> RemoteStrategyResult: + """Send strategy request to remote seller and handle payment.""" + + async with x402HttpxClient( + account=self._account, + base_url=self._base_url, + **self._client_args, + ) as client: + try: + response = await client.post(self._endpoint_path, json=payload.model_dump(by_alias=True)) + except PaymentAmountExceededError as exc: + raise RemoteStrategyPaymentError(str(exc)) from exc + except PaymentError as exc: + raise RemoteStrategyPaymentError(str(exc)) from exc + except httpx.HTTPError as exc: + raise RemoteStrategyError(f"HTTP error while calling remote strategy: {exc}") from exc + + if response.status_code >= 400: + try: + detail = response.json() + except Exception: # pragma: no cover - response not JSON + detail = response.text + raise RemoteStrategyError(f"Remote strategy request failed ({response.status_code}): {detail}") + + try: + strategy_response = StrategyResponse.model_validate(response.json()) + except Exception as exc: # pragma: no cover - validation runtime + raise RemoteStrategyError(f"Failed to parse remote strategy response: {exc}") from exc + + settlement = response.headers.get("X-PAYMENT-RESPONSE") + + return RemoteStrategyResult( + request=payload, + response=strategy_response, + settlement_header=settlement, + ) + + +_remote_client_lock = asyncio.Lock() +_remote_client: Optional[RemoteStrategyClient] = None + + +async def get_remote_strategy_client() -> RemoteStrategyClient: + global _remote_client + + if _remote_client is not None: + return _remote_client + + async with _remote_client_lock: + if _remote_client is None: + _remote_client = RemoteStrategyClient() + return _remote_client + + +def build_strategy_request( + agent_id: str, + exchange_cfg: Dict, + strategy_cfg: Dict, + account_snapshot: Dict, + positions: List[Dict], + cycle: int, +) -> StrategyRequest: + """Construct a StrategyRequest from local agent context.""" + + platform = exchange_cfg.get("type", "aster").lower() + owner = exchange_cfg.get("account_id") or exchange_cfg.get("user") + asset_symbol = strategy_cfg.get("quote_asset", "USDC") + + balance = BalanceSnapshot( + asset=asset_symbol, + amount=float(account_snapshot.get("available_balance", 0.0)), + ) + + position_snapshots: List[PositionSnapshot] = [] + for pos in positions: + metadata = { + k: v + for k, v in pos.items() + if k + not in { + "symbol", + "side", + "position_amt", + "entry_price", + "mark_price", + "leverage", + "unrealized_profit", + } + } + position_snapshots.append( + PositionSnapshot( + symbol=pos.get("symbol", ""), + size=float(pos.get("position_amt") or pos.get("size") or 0.0), + side=pos.get("side"), + entry_price=pos.get("entry_price"), + mark_price=pos.get("mark_price"), + leverage=pos.get("leverage"), + unrealized_pnl=pos.get("unrealized_profit"), + metadata=metadata, + ) + ) + + account_model = AccountSnapshot( + platform=platform, + owner=owner, + balance=balance, + equity=float(account_snapshot.get("total_wallet_balance", 0.0)), + positions=position_snapshots, + raw={"account": account_snapshot}, + ) + + risk_cfg = strategy_cfg.get("risk_management", {}) + + preference = PreferenceSnapshot( + leverage=risk_cfg.get("max_leverage"), + risk_tolerance=strategy_cfg.get("risk_profile"), + time_horizon=f"{strategy_cfg.get('scan_interval_minutes', 5)}m", + notes=strategy_cfg.get("preference_notes"), + ) + + constraints = [] + if risk_cfg: + constraints.append( + f"Max positions {risk_cfg.get('max_positions', 'n/a')} / Max leverage {risk_cfg.get('max_leverage', 'n/a')}x" + ) + constraints.append( + f"Stop loss {risk_cfg.get('stop_loss_pct', 'n/a')}% / Take profit {risk_cfg.get('take_profit_pct', 'n/a')}%" + ) + + symbols = strategy_cfg.get("default_coins", []) + + telemetry = { + "agentId": agent_id, + "cycle": cycle, + "platform": platform, + "remoteEndpoint": get_settings().remote_x402_endpoint, + } + + objectives = strategy_cfg.get( + "objectives", + f"Generate actionable strategy guidance for agent {agent_id}", + ) + + return StrategyRequest( + account=account_model, + preferences=preference, + objectives=objectives, + constraints=[c for c in constraints if c], + telemetry=telemetry, + symbols=symbols, + ) + + diff --git a/backend/src/roma_trading/core/strategy_advisor.py b/backend/src/roma_trading/core/strategy_advisor.py new file mode 100644 index 0000000..44c28c0 --- /dev/null +++ b/backend/src/roma_trading/core/strategy_advisor.py @@ -0,0 +1,162 @@ +"""Strategy advisory service for x402-paid requests.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any, Dict + +import dspy +from loguru import logger + +from roma_trading.api.schemas.x402 import StrategyRecommendation, StrategyRequest +from roma_trading.config import get_settings +from roma_trading.core.chat_service import get_chat_service + + +class StrategyAdvisorSignature(dspy.Signature): + """DSPy signature for generating strategy recommendations.""" + + system_prompt: str = dspy.InputField(desc="Instruction set for the strategy advisor") + account_context: str = dspy.InputField(desc="Structured snapshot of account, positions, and balances") + objectives: str = dspy.InputField(desc="Primary objectives and constraints") + response_json: str = dspy.OutputField(desc="JSON response with summary, steps, risk notes") + + +class StrategyAdvisor: + """High-level service for orchestrating LLM-based strategy suggestions.""" + + def __init__(self) -> None: + self.settings = get_settings() + + async def generate(self, payload: StrategyRequest) -> StrategyRecommendation: + """Generate a trading strategy recommendation from the provided payload.""" + + chat_service = get_chat_service() + lm = chat_service.get_llm() + + system_prompt = self._build_system_prompt() + account_context = self._build_account_context(payload) + objectives = payload.objectives or "Generate actionable delta-balanced strategy guidance." + + logger.debug("Generating strategy recommendation via LLM") + with dspy.context(lm=lm): + advisor = dspy.ChainOfThought(StrategyAdvisorSignature) + result = advisor( + system_prompt=system_prompt, + account_context=account_context, + objectives=objectives, + ) + + recommendation_dict = self._parse_response(result.response_json) + return StrategyRecommendation(**recommendation_dict) + + def _build_system_prompt(self) -> str: + disclaimer = self.settings.strategy_disclaimer_text or "" + model_hint = self.settings.strategy_model_id or "model-config" + return ( + "You are an AI trading strategist for ROMA-01. Produce concise, risk-aware " + "recommendations for perpetual futures positions. Always return valid JSON with " + "keys: summary (string), steps (array of strings), risk_notes (array of strings), " + "confidence (number between 0 and 1), rationale (string). Reference leverage, " + "position sizing, and hedging ideas. Include hyperliquid/aster nuances where relevant. " + f"Tag ideas suitable for the configured model '{model_hint}'. " + "Do not include markdown or additional commentary outside JSON. " + "If data is insufficient, state limitations in risk_notes. " + f"Reminder: {disclaimer}" + ) + + def _build_account_context(self, payload: StrategyRequest) -> str: + account = payload.account + preferences = payload.preferences + + lines = [f"Platform: {account.platform}"] + + if account.owner: + lines.append(f"Owner: {account.owner}") + + if account.balance: + lines.append( + f"Balance: {account.balance.amount:.4f} {account.balance.asset.upper()}" + ) + + if account.equity is not None: + lines.append(f"Equity: {account.equity:.4f} {account.balance.asset if account.balance else 'USDC'}") + + if preferences: + if preferences.leverage is not None: + lines.append(f"Preferred leverage: {preferences.leverage}x") + if preferences.risk_tolerance: + lines.append(f"Risk tolerance: {preferences.risk_tolerance}") + if preferences.time_horizon: + lines.append(f"Time horizon: {preferences.time_horizon}") + if preferences.notes: + lines.append(f"Preference notes: {preferences.notes}") + + if payload.constraints: + lines.append("Constraints: " + "; ".join(payload.constraints)) + + lines.append("Positions:") + if account.positions: + for pos in account.positions: + side = (pos.side or "flat").upper() + entry = f"entry {pos.entry_price:.2f}" if pos.entry_price is not None else "entry unknown" + mark = f"mark {pos.mark_price:.2f}" if pos.mark_price is not None else "mark unknown" + leverage = f", leverage {pos.leverage}x" if pos.leverage else "" + lines.append( + f"- {pos.symbol}: {side} {pos.size} contracts ({entry}, {mark}{leverage})" + ) + if pos.unrealized_pnl is not None: + lines.append(f" Unrealized PnL: {pos.unrealized_pnl:+.2f}") + if pos.metadata: + trimmed_meta = { + k: v + for k, v in pos.metadata.items() + if isinstance(v, (int, float, str)) + } + if trimmed_meta: + lines.append(f" Meta: {json.dumps(trimmed_meta, ensure_ascii=False)}") + else: + lines.append("- No open positions") + + if payload.telemetry: + lines.append("Telemetry: " + json.dumps(payload.telemetry, ensure_ascii=False)) + + return "\n".join(lines) + + def _parse_response(self, response_text: str) -> Dict[str, Any]: + try: + json_start = response_text.find("{") + json_end = response_text.rfind("}") + 1 + if json_start == -1 or json_end <= json_start: + raise ValueError("No JSON object found in response") + payload = json.loads(response_text[json_start:json_end]) + except Exception as exc: # pragma: no cover - fallback for malformed outputs + logger.warning(f"Failed to parse strategy JSON, using fallback: {exc}") + payload = { + "summary": "Unable to parse model output. Provide manual review.", + "steps": [], + "risk_notes": ["Model output could not be parsed."], + "confidence": 0.0, + "rationale": response_text.strip(), + } + + payload.setdefault("steps", []) + payload.setdefault("risk_notes", []) + payload.setdefault("summary", "No strategy generated.") + payload.setdefault("confidence", 0.0) + payload.setdefault("rationale", "") + + # Sanitise types + try: + payload["confidence"] = float(payload.get("confidence", 0.0)) + except (TypeError, ValueError): + payload["confidence"] = 0.0 + + payload["steps"] = [str(step) for step in payload.get("steps", [])] + payload["risk_notes"] = [str(note) for note in payload.get("risk_notes", [])] + payload["summary"] = str(payload.get("summary", "")) + payload["rationale"] = str(payload.get("rationale", "")) + + return payload + diff --git a/docs/development/x402-integration-requirements.md b/docs/development/x402-integration-requirements.md new file mode 100644 index 0000000..bc35091 --- /dev/null +++ b/docs/development/x402-integration-requirements.md @@ -0,0 +1,182 @@ +## 项目背景 + +roma-01 作为多链量化交易智能体平台,当前通过后端策略引擎与多种 DEX 工具包(例如 Hyperliquid、Aster)为用户提供交易服务。为了实现按调用计费、降低外部集成门槛,并让其他节点复用已有的模型能力,需要引入 Coinbase 的 x402 支付协议。目标是同时支持: + +- **Seller**:本地部署 roma-01,暴露付费 `POST /x402` 接口,对外提供策略建议; +- **Buyer**:本地部署 roma-01,但不运行大模型,通过 x402 支付调用他人暴露的 `/x402`,获取策略建议后交由本地代理执行。 + +参考官方文档: + +## 项目目标 + +1. 在 roma-01 后端暴露 `POST /x402` 接口,提供按次付费的策略建议服务。 +2. 集成 x402 支付流程,仅在支付成功后调用本地大模型和策略逻辑。 +3. 支持 Hyperliquid、Aster 持仓/余额等上下文的标准化输入与提示构造。 +4. 建立日志、审计、监控能力,追踪支付与模型调用的生命周期。 +5. 支持 roma-01 作为 Buyer,通过 x402 调用远程 `/x402` 接口获取策略建议。 +6. 在 Buyer 模式下,将远程返回的建议与本地执行/日志模块打通,支持回退到本地模型。 +7. 为未来扩展多资产、多计费策略、多服务端选择预留配置。 + +## 角色与用户故事 + +### 角色 + +- **外部调用方(第三方 Buyer)**:希望通过简单的 HTTP 请求购买一次策略建议服务。 +- **roma-01 平台(Seller 模式)**:提供付费访问的策略建议接口,同时确保安全与风控。 +- **roma-01 平台(Buyer 模式)**:部署者不运行本地模型,通过付费调用其他 Seller 获取策略建议,再由本地代理执行。 +- **运营/风控人员**:需要审计支付记录、模型输出,排查异常。 + +### 核心用户故事 + +1. 作为第三方或 Buyer,我可以携带账户持仓数据调用任意部署的 `POST /x402`,在支付完成后得到策略建议。 +2. 作为 Seller,我可以确保只对已支付请求调用本地大模型,控制计算成本并避免滥用。 +3. 作为 Buyer 模式的 roma-01 用户,我可以在本地配置远程 `x402` 端点与私钥,无需运行大模型即可获得策略建议。 +4. 作为运营人员,我可以通过日志和监控信息回溯每一次付费请求、支付结算与策略输出。 + +## 功能范围 + +### 必须实现 + +1. **Server(Seller)能力**: + - 请求规范化:定义账户、持仓、偏好、目标等字段并进行 schema 校验。 + - x402 支付交互:返回 402 Payment Required(包含价格、网络、facilitator、nonce 等);校验 receipt,确认金额、nonce、过期时间;防重放、防重复支付。 + - 策略生成:使用本地大模型与工具包生成结构化策略建议。 + - 响应规范:包含 `strategy`、`disclaimer`、`payment`、`metadata`。 + - 可观测性:记录 requestId/paymentId/modelCallId,输出日志、指标、追踪信息。 +2. **Client(Buyer)能力**: + - 在配置远程 `/x402` 端点、钱包地址与私钥后,自动完成 402→支付→重试流程。 + - 发送与本地一致的 `StrategyRequest` 数据结构,兼容 Hyperliquid/Aster。 + - 解析返回的策略建议与 `X-PAYMENT-RESPONSE` 结算信息,写入本地日志流水。 + - 支持失败兜底(重试、本地模型 fallback、人工提示)。 + +### 可选/后续实现 + +- 支持多级计费(按模型、按市场、按响应大小)。 +- 引入积分或订阅机制,以降低频繁支付的摩擦。 +- 提供策略回放或模拟执行接口。 +- 扩展更多链上资产或 facilitator。 + +### 非范围 + +- 不负责代为执行交易或持久化账户秘钥。 +- 不提供 Web 前端入口,仅提供 API。 +- 不涉及用户注册或身份验证流程(除可选签名验证)。 + +## 业务流程 + +### Seller 流程 + +1. Buyer 首次调用 `POST /x402`(无支付收据)。 +2. 服务返回 402 Payment Required,附带支付指令(价格、资产、network、facilitator、nonce、截止时间)。 +3. Buyer 按指令完成链上支付,获取支付收据。 +4. Buyer 携带收据再次调用 `POST /x402`。 +5. 服务端调用 facilitator 验证收据,确认金额、nonce、deadline;成功后调用本地大模型生成策略。 +6. 返回 200 响应,包括策略建议、免责声明、支付信息、metadata。 +7. 写入日志、指标、支付流水,供审计监控。 + +### Buyer 流程 + +1. 本地 roma-01 配置远程 `REMOTE_X402_ENDPOINT`、钱包地址与私钥。 +2. 策略生成阶段检查配置,若启用远程模式,构造 `StrategyRequest`(含持仓、余额等)。 +3. 通过 `x402` 客户端发送请求,若收到 402,则自动根据支付指令签名授权并提交交易。 +4. 支付结算成功后自动重试,获取策略响应。 +5. 本地记录策略、支付信息,交由执行引擎处理;若失败,按配置回退到本地模型或返回等待。 + +## 请求与响应示例 + +### 请求体(示例) + +```json +{ + "account": { + "platform": "hyperliquid", + "positions": [ + { "symbol": "ETH-PERP", "size": 1.2, "entryPx": 3150.5 }, + { "symbol": "BTC-PERP", "size": -0.5, "entryPx": 62000 } + ], + "balance": { "asset": "USDC", "amount": 2500 } + }, + "preferences": { + "leverage": 3, + "riskTolerance": "medium", + "timeHorizon": "24h" + }, + "objectives": "Optimize delta-neutral coverage while preserving upside", + "symbols": ["ETH-PERP", "BTC-PERP", "BNB-PERP"] +} +``` + +### 成功响应 + +```json +{ + "strategy": { + "summary": "Reduce ETH long exposure by 25% and hedge BTC short with call options", + "steps": [ + "Close 0.3 ETH perpetual long to reduce exposure", + "Open 0.2 BTC perpetual long to neutralize downside", + "Set conditional order: buy 0.1 BTC if price drops 3%" + ], + "riskNotes": [ + "Monitor funding rate shifts over the next 6h", + "Consider additional collateral if volatility spikes" + ] + }, + "payment": { + "receiptId": "fac-20251107-123456", + "amount": "5", + "asset": "USDC", + "network": "base" + }, + "metadata": { + "requestId": "req-2f7b9", + "model": "roma-gpt-strategy-v1", + "generatedAt": "2025-11-07T10:15:00Z" + } +} +``` + +## 非功能性需求 + +- **性能**: + - Seller:支付验证 + 模型生成总耗时 ≤ 5s;模型阶段可配置超时与重试。 + - Buyer:远程调用超时时间 ≤ 10s,可配置重试与断路策略。 +- **可用性**: + - Seller:服务可用性 ≥ 99.5%,支付失败需返回可重试信息。 + - Buyer:远程服务不可达时,需在 1 分钟内切换至 fallback 或告警。 +- **安全**: + - Seller:输入校验、防重放;敏感字段脱敏或加密;可选买方签名验证。 + - Buyer:私钥存储于安全介质,支付授权过程加密;敏感配置不写入仓库。 +- **审计**: + - Seller:保存 ≥ 90 天的请求、支付、模型日志。 + - Buyer:记录远程 requestId/paymentId/disclaimer,满足成本核算与追溯。 +- **配置与默认值**: + - Seller 侧 `x402_enabled` 默认 `false`,需要显式配置收款地址、价格、网络、facilitator URL 等;所有值从环境变量/配置文件读取,避免硬编码。 + - Buyer 侧 `remote_strategy_enabled` 默认 `false`,启用后需提供远程端点、钱包地址/私钥、网络与预算;同样通过配置文件或环境变量注入。 + +## 依赖与前置条件 + +- 获取 Coinbase x402 facilitator 凭据与 Base 网络支付配置(Seller)。 +- 获取或配置 Buyer 钱包地址与私钥,确保具备所需链上资产(Buyer)。 +- 确定 roma-01 内部使用的大模型接口(本地或外部服务),并评估成本与 SLA。 +- 整理 Hyperliquid、Aster 工具包提供的数据字段,确保与请求体对齐。 +- 准备测试网或 sandbox 环境,验证 Seller 402 支付闭环及 Buyer 自动支付流程。 + +## 验收标准 + +1. 提供演示脚本(HTTPie/Postman)完成 Seller 侧 402→支付→200 流程。 +2. Seller 402 响应包含完整支付指令;支付成功后返回策略建议、免责声明、支付信息。 +3. Buyer 侧提供示例脚本/CLI,演示自动支付并获取策略建议。 +4. 日志中能够关联 requestId、paymentId、modelCallId,并记录远程调用来源。 +5. 关键配置(价格、facilitator、模型 ID、远程端点、私钥等)可通过环境变量或配置中心管理。 +6. 至少一条 Seller 集成测试与一条 Buyer 集成测试验证端到端流程。 + +## 风险与缓解 + +- **支付延迟或失败**:Seller 返回可重试信息,Buyer 提供重试/回退逻辑;必要时缓存支付状态。 +- **模型不可用或响应质量低**:Seller 设置熔断/降级;Buyer 支持切换至本地模型或提示等待。 +- **数据泄露**:限制输入字段、脱敏敏感信息;Buyer 私钥保护、请求加密传输。 +- **监管合规**:Seller 在响应中加入标准免责声明;Buyer 显示远程 disclaimer 并记录来源。 +- **跨主体依赖**:Buyer 需评估远程服务 SLA 与成本,必要时配置多服务端或 discovery 以分散风险。 + + diff --git a/docs/development/x402-integration-technical-design.md b/docs/development/x402-integration-technical-design.md new file mode 100644 index 0000000..adc1d10 --- /dev/null +++ b/docs/development/x402-integration-technical-design.md @@ -0,0 +1,247 @@ +## 架构概览 + +roma-01 的 x402 集成同时覆盖 **Seller(服务端)** 与 **Buyer(客户端)** 两种部署形态: + +- Seller:基于 FastAPI 暴露 `/x402`,由支付中间件、策略生成器、审计组件构成; +- Buyer:在本地代理循环中引入远程策略客户端,通过 x402 支付获取外部策略建议。 + +模块划分: + +1. **API 层(Seller)**:暴露 `POST /x402`,负责请求校验、速率限制、请求追踪。 +2. **x402 支付中间件(Seller)**:处理 402 往返、验证 Coinbase facilitator 支付收据、维护支付状态。 +3. **策略生成器(Seller)**:整合大模型和工具包生成策略建议,并附带免责声明、支付信息。 +4. **远程策略客户端(Buyer)**:包装 x402 httpx 客户端,负责发起请求、支付、重试、解析响应。 +5. **代理协调层(Buyer)**:在策略阶段选择本地/远程模式,处理 fallback、日志与执行。 +6. **数据存储与审计**:统一记录 requestId/paymentId/modelCallId、支付流水、远程来源等信息。 +7. **配置管理**:通过环境变量或配置文件注入价格、钱包、facilitator、远程端点、模型参数等。 + +## 组件职责 + +### API 层(`backend/src/roma_trading/api/routes/x402.py`) + +- 定义 pydantic 模型校验请求体。 +- 检查 Header 中的 `X-X402-Receipt` 或请求体中的 `paymentReceipt`。 +- 没有有效收据时返回 402 Payment Required 格式的响应(JSON 或 Problem+JSON)。 +- 将带收据的请求交由策略生成服务处理。 + +### x402 支付中间件(`x402.fastapi.middleware.require_payment`) + +- 通过配置注入价格、资产、facilitator、网络等信息。 +- 首次请求返回 402 Payment Required(自动生成 payment requirements)。 +- 验证 Buyer 提供的 `X-PAYMENT` header,调用 facilitator 校验/结算。 +- 将验证结果写入 `request.state`,供路由层读取。 + +### 策略生成器(Seller,`backend/src/roma_trading/core/strategy_advisor.py`) + +- 接收标准化账户信息与偏好。 +- 使用提示模板构造对大模型的请求,或调用本地策略生成逻辑。 +- 输出结构化策略,包括 summary、steps、riskNotes、confidence、rationale。 +- 支持多模型或 fallback,输出元数据(modelId、latency)。 +- 在响应中附带配置化免责声明(`STRATEGY_DISCLAIMER_TEXT`)。 + +### 远程策略客户端(Buyer,计划新增 `backend/src/roma_trading/core/remote_strategy_client.py`) + +- 封装 `x402.clients.httpx`,自动执行 402→支付→重试流程。 +- 负责加载 Buyer 配置(远程端点、钱包、网络、资产、支付上限)。 +- 将 roma-01 本地的账户/持仓数据映射为 `StrategyRequest` schema。 +- 解析返回的 `StrategyResponse`、`X-PAYMENT-RESPONSE`,输出标准数据结构。 + +### 代理协调层(Buyer,增强 `TradingAgent` 或策略调用链) + +- 根据配置切换本地/远程策略生成模式。 +- 若远程调用失败,支持重试或回退至本地 `StrategyAdvisor`。 +- 将远程策略结果写入现有日志、执行流水。 + +### 审计与监控 + +- **日志**:在现有 `decision_logger` 基础上增加支付和策略追踪;记录 requestId、paymentId、nonce、modelCallId。 +- **指标**:新增支付成功率、402 返回率、模型耗时等指标,接入现有监控(如 Prometheus)。 +- **追踪**:沿用 OpenTelemetry/自定义追踪 ID,贯穿整条链路。 + +## 时序流程 + +### Seller 端时序(服务端) + +```text +Buyer -> API: POST /x402 (payload) +API -> Buyer: 402 Payment Required (price, asset, network, facilitator, nonce) +Buyer -> Facilitator: pay() +Facilitator -> Buyer: paymentReceipt +Buyer -> API: POST /x402 (payload + receipt) +Middleware -> Facilitator: verify(payment) +Facilitator -> Middleware: is_valid +Middleware -> Route: attach payment details +Route -> StrategyAdvisor: generate strategy +StrategyAdvisor -> LLM: prompt -> response +LLM -> StrategyAdvisor: strategy JSON +StrategyAdvisor -> Route: structured strategy +Route -> Buyer: 200 OK (strategy, disclaimer, payment info, metadata) +``` + +### Buyer 端时序(客户端) + +```text +Agent -> RemoteClient: requestStrategy(account context) +RemoteClient -> Seller: POST /x402 (payload) +Seller -> RemoteClient: 402 Payment Required +RemoteClient -> Wallet: sign payment authorization (EIP-3009) +RemoteClient -> Facilitator: submit payment +Facilitator -> RemoteClient: receipt/settlement +RemoteClient -> Seller: POST /x402 (payload + receipt) +Seller -> RemoteClient: 200 Strategy Response +RemoteClient -> Agent: structured strategy + disclaimer + payment metadata +Agent -> Execution: apply decisions / log results +``` + +## 数据模型 + +| 实体 | 关键字段 | 说明 | +| ---------------- | ---------------------------------------------------------- | ---------------------- | +| PaymentSession | sessionId, nonce, price, asset, network, status, expiresAt | 跟踪支付状态与生命周期 | +| StrategyRequest | requestId, accountPlatform, payloadHash, paymentSessionId | 请求上下文与支付关联 | +| StrategyResponse | responseId, requestId, modelId, strategySummary, createdAt | 模型输出与元数据 | + +Buyer 侧还需维护远程调用日志(requestId、paymentId、endpoint),供成本核算。 + +## 请求与响应规范 + +### Seller 侧 + +- Header: + - `X-PAYMENT`:Buyer 提交的支付凭据(base64)。 + - `Idempotency-Key`:可选,用于幂等控制。 +- 请求体字段:与需求文档一致,另可包含 `telemetry`(客户端版本、环境)。 +- 402 响应示例: + +```json +{ + "type": "https://roma.sentient.ai/errors/payment-required", + "title": "Payment Required", + "price": "5", + "asset": "USDC", + "network": "base", + "paymentPointer": "https://facilitator.cdp.coinbase.com/payments/xyz", + "nonce": "19f3d16c-...", + "expiresAt": "2025-11-07T10:16:00Z" +} +``` + +- 200 响应:包含策略、免责声明字段、支付与 metadata,见需求示例。 +- 响应头:`X-PAYMENT-RESPONSE`(base64),包含 settle 结果,可选。 + +### Buyer 侧 + +- 自动读取 Seller 返回的 402 `accepts` 字段,生成支付交易。 +- 支付成功后在本地缓存支付指令与 requestId,防止重复支付。 +- 将 Seller 返回的 `StrategyResponse` 映射为本地策略执行结构。 + +## 配置项 + +### Seller 相关(`Settings` / `.env`) + +- `X402_ENABLED`(`x402_enabled`):是否启用付费接口,默认 `false`。 +- `X402_PRICE_USDC`(`x402_price_usdc`):每次调用收取的 USDC 金额,默认 `5.0`,建议显式配置。 +- `X402_NETWORK`(`x402_network`):支付网络,默认 `base-sepolia`(测试网)。 +- `X402_PAY_TO_ADDRESS`:接收支付的钱包地址(必填,无默认)。 +- `X402_PAYMENT_DESCRIPTION` / `X402_RESOURCE_DESCRIPTION` / `X402_RESOURCE_MIME_TYPE`:用于生成 payment requirements 的元信息。 +- `X402_FACILITATOR_URL`:可选,自定义 facilitator URL;未配置时使用 SDK 默认。 +- `X402_CDP_API_KEY_ID` / `X402_CDP_API_KEY_SECRET`:使用 Coinbase 托管 facilitator 时的 Bearer 鉴权信息。 +- `X402_MAX_DEADLINE_SECONDS`(`x402_max_deadline_seconds`):支付有效期,默认 `120` 秒。 +- `X402_DISCOVERABLE`(`x402_discoverable`):是否参与 discovery,默认 `True`。 +- `STRATEGY_MODEL_ID`、`STRATEGY_PROMPT_TEMPLATE`:本地策略模型选择与提示模板路径。 +- `STRATEGY_DISCLAIMER_TEXT`:响应中附带的免责声明(已提供默认文案,可自定义)。 + +### Buyer 相关(新增) + +- `REMOTE_STRATEGY_ENABLED`:是否启用远程策略模式,默认 `false`。 +- `REMOTE_X402_ENDPOINT`:目标 Seller `/x402` 完整 URL(必填,启用时校验)。 +- `REMOTE_X402_NETWORK`:远程支付网络(可选,未配置时使用 Seller 提示)。 +- `REMOTE_X402_PAYMENT_ASSET`:远程支付资产,默认 `USDC`。 +- `REMOTE_X402_ACCOUNT`:Buyer 钱包地址(启用时必填)。 +- `REMOTE_X402_PRIVATE_KEY`:Buyer 钱包私钥或签名代理,建议通过密钥管理注入。 +- `REMOTE_X402_PRICE_CAP`:单次调用最大愿付费用(可选,用于成本控制)。 +- `REMOTE_X402_DISCOVERY`:facilitator discovery endpoint(可选,用于动态发现 Seller)。 +- `REMOTE_FALLBACK_MODE`:失败兜底策略,默认 `local`(回退至本地模型)。 +- `REMOTE_TIMEOUT_SECONDS`:远程调用超时时间,默认 `10` 秒。 +- `REMOTE_RETRY_LIMIT`:远程调用重试次数,默认 `1`。 + +所有配置通过 `.env`、`trading_config.yaml` 或密钥管理系统注入,避免硬编码;Buyer 私钥等敏感信息需使用安全存储。 + +## 安全设计 + +### Seller + +- 对请求体进行 JSON schema 校验,限制字段大小与类型。 +- 验证支付收据时校验 nonce、金额、防止重放;使用缓存或数据库记录已消费收据。 +- 可选:要求 Buyer 提供附加签名或 API Key,结合速率限制。 +- 审计日志中对敏感数据(余额、仓位)脱敏或加密。 + +### Buyer + +- 私钥通过环境变量、安全存储或远程签名服务加载,不写入仓库。 +- 支付交易前进行价格/资产校验,避免超出预算。 +- 全程使用 HTTPS,验证远程服务证书,防止中间人攻击。 +- 记录远程 disclaimer 与来源,保障合规。 + +## 错误处理与重试 + +### Seller + +- 支付验证失败:返回 402 + 具体 `error`/`errorCode`(如 `invalid_receipt`、`expired`、`amount_mismatch`)。 +- 模型超时:返回 503/504,保留 paymentSession,提示 Buyer 稍后重试。 +- 内部错误:返回 500,附 `traceId`,记录日志并告警。 + +### Buyer + +- 支付失败:根据 facilitator 返回的错误码重试或回退;必要时提示用户补充余额。 +- 网络/超时:按 `REMOTE_RETRY_LIMIT` 重试,依然失败则根据 `REMOTE_FALLBACK_MODE` 处理。 +- 解析失败:若远程返回不可解析,记录原始响应并回退到本地模型/等待模式。 + +## 测试策略 + +### 单元测试 + +- Seller:支付指令生成、facilitator 校验逻辑、策略 JSON 解析、免责声明字段。 +- Buyer:远程请求构造、支付头生成、响应解析、错误回退。 + +### 集成测试 + +- Seller:使用 sandbox facilitator 演示 402→支付→200 闭环,验证 settle 与响应头。 +- Buyer:模拟远程服务器,确保自动支付与策略解析可用;覆盖失败重试与 fallback。 +- Buyer+Seller:端到端测试(本地起 Seller + 本地 Buyer 调用)。 + +### 负载与安全 + +- Seller:评估高并发支付验证/模型调用;测试重放、伪造收据、异常 payload。 +- Buyer:压力测试连续多次远程调用的超时与重试;验证密钥泄露防护与错误处理。 + +## 开发计划 + +1. **MVP(Sprint 1)**: + - 完成 Seller `/x402` 路由、支付中间件配置、本地策略生成、日志记录。 + - 完成 Buyer 远程客户端基础调用与策略落地。 +2. **增强(Sprint 2)**: + - Seller:完善审计、指标、错误码、配置化价格。 + - Buyer:增加重试/回退策略、超时监控、成本追踪。 +3. **扩展(Sprint 3)**: + - 多模型、多计费、discovery 动态发现服务、策略聚合与比较。 + - 支持多远程端点路由与 SLA 监控。 + +## 未决问题 + +- Coinbase facilitator 的认证方式(已确定使用 CDP API Key Bearer 鉴权)。 +- 模型服务调用限额与成本控制策略(当前阶段暂不设置限额,由运营监控实际消耗)。 +- 是否需要对策略输出进行合规审核或免责声明处理(已确定通过 `STRATEGY_DISCLAIMER_TEXT` 字段在响应中附带标准免责声明)。 +- Hyperliquid/Aster 数据格式差异对通用 schema 的影响(通过统一的公共 schema + 平台适配器映射字段,需在实现阶段完善映射规则)。 +- Buyer fallback 策略与成本预算管理方式(是否需要自动限额或多服务端路由)。 +- Buyer 私钥管理标准(是否引入远程签名服务或硬件钱包)。 + +## 实现进度(2025-11) + +- ✅ Seller `/x402` 路由、支付中间件接入、策略响应(含免责声明/支付信息) +- ✅ 配置化的 Seller 参数(价格、地址、facilitator、模型) +- ✅ 文档与需求更新(Seller/Buyer 双模式说明) +- ⏳ Buyer 远程策略客户端、代理调用逻辑、回退策略(计划中) +- ⏳ 指标监控、审计增强、discovery 集成(计划中) + +