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
64 changes: 63 additions & 1 deletion python/valuecell/agents/strategy_agent/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
ComposeContext,
FeatureVector,
HistoryRecord,
MarketType,
PortfolioView,
StrategyStatus,
StrategySummary,
Expand All @@ -25,6 +26,7 @@
TradeInstruction,
TradeSide,
TradeType,
TradingMode,
TxResult,
TxStatus,
UserRequest,
Expand Down Expand Up @@ -131,6 +133,57 @@ async def run_once(self) -> DecisionCycleResult:
compose_id = generate_uuid("compose")

portfolio = self._portfolio_service.get_view()
# LIVE mode: sync cash from exchange free balance; set buying power to cash
try:
if (
self._request.exchange_config.trading_mode == TradingMode.LIVE
and hasattr(self._execution_gateway, "fetch_balance")
):
balance = await self._execution_gateway.fetch_balance()
free_map = {}
free_section = (
balance.get("free") if isinstance(balance, dict) else None
)
if isinstance(free_section, dict):
free_map = {
str(k).upper(): float(v or 0.0) for k, v in free_section.items()
}
else:
# Handle nested per-currency dict shapes
iterable = balance.items() if isinstance(balance, dict) else []
for k, v in iterable:
if isinstance(v, dict) and "free" in v:
try:
free_map[str(k).upper()] = float(v.get("free") or 0.0)
except Exception:
continue
# Derive quote currencies from symbols, fallback to common USD-stable quotes
quotes = []
for sym in self._symbols or []:
s = str(sym).upper()
if "/" in s and len(s.split("/")) == 2:
quotes.append(s.split("/")[1])
elif "-" in s and len(s.split("-")) == 2:
quotes.append(s.split("-")[1])
# Deduplicate preserving order
quotes = list(dict.fromkeys(quotes))
free_cash = 0.0
if quotes:
for q in quotes:
free_cash += float(free_map.get(q, 0.0) or 0.0)
else:
for q in ("USDT", "USD", "USDC"):
free_cash += float(free_map.get(q, 0.0) or 0.0)
portfolio.cash = float(free_cash)
if self._request.exchange_config.market_type == MarketType.SPOT:
portfolio.buying_power = max(0.0, float(portfolio.cash))
except Exception:
# If syncing fails, continue with existing portfolio view
pass
# VIRTUAL mode: cash-only for spot; derivatives keep margin-based buying power
if self._request.exchange_config.trading_mode == TradingMode.VIRTUAL:
if self._request.exchange_config.market_type == MarketType.SPOT:
portfolio.buying_power = max(0.0, float(portfolio.cash))
# Use fixed 1-minute interval and lookback of 4 hours (60 * 4 minutes)
candles = await self._market_data_source.get_recent_candles(
self._symbols, "1m", 60 * 4
Expand Down Expand Up @@ -414,10 +467,19 @@ def _build_summary(
try:
view = self._portfolio_service.get_view()
unrealized = float(view.total_unrealized_pnl or 0.0)
equity = float(view.total_value or 0.0)
# In LIVE mode, treat equity as available cash (disallow financing)
try:
mode = getattr(self._request.exchange_config, "trading_mode", None)
except Exception:
mode = None
if str(mode).upper() == "LIVE":
equity = float(getattr(view, "cash", None) or 0.0)
else:
equity = float(view.total_value or 0.0)
except Exception:
# Fallback to internal tracking if portfolio service is unavailable
unrealized = float(self._unrealized_pnl or 0.0)
# Fallback equity uses initial capital when view is unavailable
equity = float(self._request.trading_config.initial_capital or 0.0)

# Keep internal state in sync (allow negative unrealized PnL)
Expand Down
53 changes: 39 additions & 14 deletions python/valuecell/agents/strategy_agent/decision/composer.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
Constraints,
LlmDecisionAction,
LlmPlanProposal,
MarketType,
PriceMode,
TradeInstruction,
TradeSide,
Expand Down Expand Up @@ -146,19 +147,28 @@ def _init_buying_power_context(
max_leverage=self._request.trading_config.max_leverage,
)

# Compute equity (prefer total_value, fallback to cash + net_exposure)
if getattr(context.portfolio, "total_value", None) is not None:
equity = float(context.portfolio.total_value or 0.0)
# Compute equity based on market type:
if self._request.exchange_config.market_type == MarketType.SPOT:
# Spot: use available cash as equity
equity = float(getattr(context.portfolio, "cash", 0.0) or 0.0)
else:
cash = float(getattr(context.portfolio, "cash", 0.0) or 0.0)
net = float(getattr(context.portfolio, "net_exposure", 0.0) or 0.0)
equity = cash + net

allowed_lev = (
float(constraints.max_leverage)
if constraints.max_leverage is not None
else 1.0
)
# Derivatives: use portfolio equity (cash + net exposure), or total_value if provided
if getattr(context.portfolio, "total_value", None) is not None:
equity = float(context.portfolio.total_value or 0.0)
else:
cash = float(getattr(context.portfolio, "cash", 0.0) or 0.0)
net = float(getattr(context.portfolio, "net_exposure", 0.0) or 0.0)
equity = cash + net

# Market-type leverage policy: SPOT -> 1.0; Derivatives -> constraints
if self._request.exchange_config.market_type == MarketType.SPOT:
allowed_lev = 1.0
else:
allowed_lev = (
float(constraints.max_leverage)
if constraints.max_leverage is not None
else 1.0
)

# Initialize projected gross exposure
price_map = context.market_snapshot or {}
Expand Down Expand Up @@ -263,7 +273,12 @@ def _normalize_quantity(
)
final_qty = qty
else:
avail_bp = max(0.0, equity * allowed_lev - projected_gross)
if self._request.exchange_config.market_type == MarketType.SPOT:
# Spot: cash-only buying power
avail_bp = max(0.0, equity)
else:
# Derivatives: margin-based buying power
avail_bp = max(0.0, equity * allowed_lev - projected_gross)
# When buying power is exhausted, we should still allow reductions/closures.
# Set additional purchasable units to 0 but proceed with piecewise logic
# so that de-risking trades are not blocked.
Expand Down Expand Up @@ -353,6 +368,12 @@ def _count_active(pos_map: Dict[str, float]) -> int:
target_qty = self._resolve_target_quantity(
item, current_qty, max_position_qty
)
# SPOT long-only: do not allow negative target quantities
if (
self._request.exchange_config.market_type == MarketType.SPOT
and target_qty < 0
):
target_qty = 0.0
# Enforce: single-lot per symbol and no direct flip. If target flips side,
# split into two sub-steps: first flat to 0, then open to target side.
sub_targets: List[float] = []
Expand Down Expand Up @@ -397,7 +418,11 @@ def _count_active(pos_map: Dict[str, float]) -> int:
if constraints.max_leverage is not None
else requested_lev
)
final_leverage = max(1.0, min(requested_lev, allowed_lev_item))
if self._request.exchange_config.market_type == MarketType.SPOT:
# Spot: long-only, no leverage
final_leverage = 1.0
else:
final_leverage = max(1.0, min(requested_lev, allowed_lev_item))
quantity = abs(delta)

# Normalize quantity through all guardrails
Expand Down
58 changes: 23 additions & 35 deletions python/valuecell/agents/strategy_agent/execution/ccxt_trading.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,24 +288,18 @@ def _build_order_params(self, inst: TradeInstruction, order_type: str) -> Dict:

return params

async def _enforce_minimums(
async def _check_minimums(
self,
exchange: ccxt.Exchange,
symbol: str,
amount: float,
price: Optional[float],
) -> float:
"""Ensure amount satisfies exchange minimums (amount and notional).

- Checks markets[symbol].limits.amount.min and info.minSz (OKX)
- If limits.cost.min exists, uses price or fetches ticker to lift amount
- Returns adjusted amount aligned to precision
"""
) -> Optional[str]:
markets = getattr(exchange, "markets", {}) or {}
market = markets.get(symbol, {})
limits = market.get("limits") or {}

# Minimum amount (contracts)
# amount minimum
min_amount = None
amt_limits = limits.get("amount") or {}
if amt_limits.get("min") is not None:
Expand All @@ -321,26 +315,17 @@ async def _enforce_minimums(
min_amount = float(min_sz)
except Exception:
min_amount = None

if min_amount is not None and amount < min_amount:
logger.info(
f" ↗️ Amount {amount} below min {min_amount}; aligning to minimum"
)
amount = min_amount
try:
amount = float(exchange.amount_to_precision(symbol, amount))
except Exception:
pass
return f"amount<{min_amount}"

# Minimum notional (cost)
# notional minimum
min_cost = None
cost_limits = limits.get("cost") or {}
if cost_limits.get("min") is not None:
try:
min_cost = float(cost_limits["min"])
except Exception:
min_cost = None

if min_cost is not None:
est_price = price
if est_price is None and exchange.has.get("fetchTicker"):
Expand All @@ -357,18 +342,8 @@ async def _enforce_minimums(
if est_price and est_price > 0:
notional = amount * est_price
if notional < min_cost:
required_amount = min_cost / est_price
logger.info(
f" ↗️ Notional {notional:.4f} below minCost {min_cost}; lifting amount"
)
try:
amount = float(
exchange.amount_to_precision(symbol, required_amount)
)
except Exception:
amount = required_amount

return amount
return f"notional<{min_cost}"
return None

async def execute(
self,
Expand Down Expand Up @@ -480,11 +455,24 @@ async def _execute_single(
except Exception:
pass

# Enforce exchange minimums (amount and notional)
# Reject orders below exchange minimums (do not lift to min)
try:
amount = await self._enforce_minimums(exchange, symbol, amount, price)
reject_reason = await self._check_minimums(exchange, symbol, amount, price)
except Exception as e:
logger.warning(f"⚠️ Could not align to minimums for {symbol}: {e}")
logger.warning(f"⚠️ Minimum check failed for {symbol}: {e}")
reject_reason = f"minimum_check_failed:{e}"
if reject_reason is not None:
logger.warning(f" 🚫 Skipping order due to {reject_reason}")
return TxResult(
instruction_id=inst.instruction_id,
instrument=inst.instrument,
side=inst.side,
requested_qty=float(inst.quantity),
filled_qty=0.0,
status=TxStatus.REJECTED,
reason=reject_reason,
meta=inst.meta,
)

# Build order params with exchange-specific defaults
params = self._build_order_params(inst, order_type)
Expand Down
33 changes: 33 additions & 0 deletions python/valuecell/agents/strategy_agent/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,39 @@ class UserRequest(BaseModel):
..., description="Trading strategy configuration"
)

@model_validator(mode="before")
@classmethod
def _infer_market_type(cls, data):
"""Infer market_type from trading_config.max_leverage when not provided.

Rule: if market_type is missing (not present in request), then
- max_leverage <= 1.0 -> SPOT
- max_leverage > 1.0 -> SWAP
"""
if not isinstance(data, dict):
return data
values = dict(data)
ex_cfg = dict(values.get("exchange_config") or {})
# Only infer when market_type is not provided by the user
mt_value = ex_cfg.get("market_type")
mt_missing = (
("market_type" not in ex_cfg)
or (mt_value is None)
or (str(mt_value).strip() == "")
)
if mt_missing:
tr_cfg = dict(values.get("trading_config") or {})
ml_raw = tr_cfg.get("max_leverage")
try:
ml = (
float(ml_raw) if ml_raw is not None else float(DEFAULT_MAX_LEVERAGE)
)
except Exception:
ml = float(DEFAULT_MAX_LEVERAGE)
ex_cfg["market_type"] = MarketType.SPOT if ml <= 1.0 else MarketType.SWAP
values["exchange_config"] = ex_cfg
return values


# =========================
# Minimal DTOs for Strategy Agent (LLM-driven composer, no StrategyHint)
Expand Down
24 changes: 16 additions & 8 deletions python/valuecell/agents/strategy_agent/portfolio/in_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from ..models import (
Constraints,
MarketType,
PortfolioView,
PositionSnapshot,
TradeHistoryEntry,
Expand Down Expand Up @@ -30,6 +31,7 @@ def __init__(
self,
initial_capital: float,
trading_mode: TradingMode,
market_type: MarketType,
constraints: Optional[Constraints] = None,
strategy_id: Optional[str] = None,
) -> None:
Expand All @@ -49,6 +51,7 @@ def __init__(
buying_power=initial_capital,
)
self._trading_mode = trading_mode
self._market_type = market_type

def get_view(self) -> PortfolioView:
self._view.ts = int(datetime.now(timezone.utc).timestamp() * 1000)
Expand Down Expand Up @@ -219,11 +222,16 @@ def apply_trades(
equity = self._view.cash + net
self._view.total_value = equity

# Approximate buying power using max leverage constraint
max_lev = (
float(self._view.constraints.max_leverage)
if (self._view.constraints and self._view.constraints.max_leverage)
else 1.0
)
buying_power = max(0.0, equity * max_lev - gross)
self._view.buying_power = buying_power
# Approximate buying power using market type policy
if self._market_type == MarketType.SPOT:
# Spot: cash-only buying power
self._view.buying_power = max(0.0, float(self._view.cash))
else:
# Derivatives: margin-based buying power
max_lev = (
float(self._view.constraints.max_leverage)
if (self._view.constraints and self._view.constraints.max_leverage)
else 1.0
)
buying_power = max(0.0, equity * max_lev - gross)
self._view.buying_power = buying_power
Loading