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

Large diffs are not rendered by default.

Binary file added frontend/src/assets/png/exchanges/blockchain.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/src/assets/png/exchanges/coinbase.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/src/assets/png/exchanges/gate.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/src/assets/png/exchanges/hyperliquid.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/src/assets/png/exchanges/mexc.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions frontend/src/assets/png/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export { default as ValueCellAgentPng } from "./agents/ValueCellAgent.png";
export { default as WarrenBuffettPng } from "./agents/WarrenBuffett.png";

export { default as BinancePng } from "./exchanges/binance.png";
export { default as BlockchainPng } from "./exchanges/blockchain.png";
export { default as CoinbasePng } from "./exchanges/coinbase.png";
export { default as GatePng } from "./exchanges/gate.png";
export { default as HyperliquidPng } from "./exchanges/hyperliquid.png";
export { default as MexcPng } from "./exchanges/mexc.png";
export { default as OkxPng } from "./exchanges/okx.png";
export { default as IconGroupPng } from "./icon-group.png";
export { default as MessageGroupPng } from "./message-group.png";
Expand Down
38 changes: 34 additions & 4 deletions frontend/src/components/valuecell/multi-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface MultiSelectProps {
disabled?: boolean;
maxSelected?: number;
maxDisplayed?: number;
creatable?: boolean;
}

export const MultiSelect = React.forwardRef<
Expand All @@ -54,12 +55,14 @@ export const MultiSelect = React.forwardRef<
disabled = false,
maxSelected,
maxDisplayed = 3,
creatable = false,
},
ref,
) => {
const [open, setOpen] = React.useState(false);
const [internalValue, setInternalValue] =
React.useState<string[]>(defaultValue);
const [inputValue, setInputValue] = React.useState("");

// Normalize options to MultiSelectOption[]
const normalizedOptions = React.useMemo<MultiSelectOption[]>(() => {
Expand Down Expand Up @@ -105,9 +108,16 @@ export const MultiSelect = React.forwardRef<
onValueChange?.([]);
};

const selectedOptions = normalizedOptions.filter((opt) =>
selectedValues.includes(opt.value),
);
const selectedOptions = React.useMemo(() => {
const opts = [...normalizedOptions];
// Add selected values that are not in options (for custom values)
selectedValues.forEach((val) => {
if (!opts.find((o) => o.value === val)) {
opts.push({ value: val, label: val });
}
});
return opts.filter((opt) => selectedValues.includes(opt.value));
}, [normalizedOptions, selectedValues]);

// Get displayed badges and count for remaining
const displayedOptions = selectedOptions.slice(0, maxDisplayed);
Expand Down Expand Up @@ -200,7 +210,12 @@ export const MultiSelect = React.forwardRef<
sideOffset={4}
>
<Command>
<CommandInput placeholder={searchPlaceholder} className="h-9" />
<CommandInput
placeholder={searchPlaceholder}
className="h-9"
value={inputValue}
onValueChange={setInputValue}
/>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandList className="max-h-[300px]">
<CommandGroup onWheel={(e) => e.stopPropagation()}>
Expand Down Expand Up @@ -247,6 +262,21 @@ export const MultiSelect = React.forwardRef<
</CommandItem>
);
})}
{creatable &&
inputValue.length > 0 &&
!normalizedOptions.some((o) => o.value === inputValue) &&
!selectedValues.includes(inputValue) && (
<CommandItem
value={inputValue}
onSelect={() => {
handleSelect(inputValue);
setInputValue("");
}}
className="cursor-pointer py-2 text-muted-foreground"
>
Create "{inputValue}"
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/constants/icons.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import {
AzurePng,
BinancePng,
BlockchainPng,
BtcPng,
CoinbasePng,
DeepSeekPng,
DogePng,
EthPng,
GatePng,
GooglePng,
HyperliquidPng,
MexcPng,
OkxPng,
OpenAiCompatiblePng,
OpenAiPng,
Expand All @@ -27,6 +32,12 @@ export const MODEL_PROVIDER_ICONS = {

export const EXCHANGE_ICONS = {
binance: BinancePng,
blockchaincom: BlockchainPng,
coinbase: CoinbasePng,
coinbaseexchange: CoinbasePng,
gate: GatePng,
hyperliquid: HyperliquidPng,
mexc: MexcPng,
okx: OkxPng,
};

Expand Down
84 changes: 73 additions & 11 deletions python/valuecell/agents/strategy_agent/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ async def run_once(self) -> DecisionCycleResult:
self._request.exchange_config.trading_mode == TradingMode.LIVE
and hasattr(self._execution_gateway, "fetch_balance")
):
logger.debug("Syncing portfolio balance from exchange in LIVE mode")
balance = await self._execution_gateway.fetch_balance()
free_map = {}
free_section = (
Expand Down Expand Up @@ -158,19 +159,60 @@ async def run_once(self) -> DecisionCycleResult:
quotes.append(s.split("-")[1])
# Deduplicate preserving order
quotes = list(dict.fromkeys(quotes))

free_cash = 0.0
total_cash = 0.0

# Sum up free and total cash from relevant quote currencies
if quotes:
for q in quotes:
free_cash += float(free_map.get(q, 0.0) or 0.0)
# Try to find total/equity in balance if available (often 'total' dict in CCXT)
# Hyperliquid/CCXT structure: balance[q]['total']
q_data = balance.get(q)
if isinstance(q_data, dict):
total_cash += float(q_data.get("total", 0.0) or 0.0)
else:
# Fallback if structure is flat or missing
total_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.account_balance = float(free_cash)
q_data = balance.get(q)
if isinstance(q_data, dict):
total_cash += float(q_data.get("total", 0.0) or 0.0)
else:
total_cash += float(free_map.get(q, 0.0) or 0.0)

logger.debug(
f"Synced balance from exchange: free_cash={free_cash}, total_cash={total_cash}, quotes={quotes}"
)

if self._request.exchange_config.market_type == MarketType.SPOT:
# Spot: Account Balance is Cash (Free). Buying Power is Cash.
portfolio.account_balance = float(free_cash)
portfolio.buying_power = max(0.0, float(portfolio.account_balance))
else:
# Derivatives: Account Balance should be Wallet Balance or Equity.
# We use total_cash (Equity) as the best approximation for account_balance
# to ensure InMemoryPortfolioService calculates Equity correctly (Equity + Unrealized).
# Note: If total_cash IS Equity, adding Unrealized PnL again in InMemoryService
# (Equity = Balance + Unreal) would double count PnL.
# However, separating Wallet Balance from Equity is exchange-specific.
# For now, we set account_balance = total_cash and rely on the fixed
# InMemoryPortfolioService to handle it (assuming Balance ~= Equity for initial sync).
portfolio.account_balance = float(total_cash)
# Buying Power is explicit Free Margin
portfolio.buying_power = float(free_cash)
# Also update free_cash field in view if it exists
portfolio.free_cash = float(free_cash)

except Exception:
# If syncing fails, continue with existing portfolio view
pass
logger.warning(
"Failed to sync balance from exchange in LIVE mode, using cached portfolio view",
exc_info=True,
)
# 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:
Expand Down Expand Up @@ -231,10 +273,36 @@ async def run_once(self) -> DecisionCycleResult:
instructions, market_snapshot
)
logger.info(f"✅ ExecutionGateway returned {len(tx_results)} results")

# Filter out failed instructions and append reasons to rationale
failed_ids = set()
failure_msgs = []
for idx, tx in enumerate(tx_results):
logger.info(
f" 📊 TxResult {idx}: {tx.instrument.symbol} status={tx.status.value} filled_qty={tx.filled_qty}"
)
if tx.status in (TxStatus.REJECTED, TxStatus.ERROR):
failed_ids.add(tx.instruction_id)
reason = tx.reason or "Unknown error"
# Format failure message with clear details
msg = f"❌ Skipped {tx.instrument.symbol} {tx.side.value} qty={tx.requested_qty}: {reason}"
failure_msgs.append(msg)
logger.warning(f" ⚠️ Order rejected: {msg}")

if failure_msgs:
# Append failure reasons to AI rationale for frontend display
prefix = "\n\n**Execution Warnings:**\n"
rationale = (
(rationale or "")
+ prefix
+ "\n".join(f"- {msg}" for msg in failure_msgs)
)

if failed_ids:
# Remove failed instructions so they don't appear in history/UI
instructions = [
inst for inst in instructions if inst.instruction_id not in failed_ids
]

trades = self._create_trades(tx_results, compose_id, timestamp_ms)
self._portfolio_service.apply_trades(trades, market_snapshot)
Expand Down Expand Up @@ -489,15 +557,9 @@ def _build_summary(
try:
view = self._portfolio_service.get_view()
unrealized = float(view.total_unrealized_pnl 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)
# Use the portfolio view's total_value which now correctly reflects Equity
# (whether simulated or synced from exchange)
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)
Expand Down
44 changes: 38 additions & 6 deletions python/valuecell/agents/strategy_agent/data/market.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,41 @@ def __init__(
self._exchange_id = exchange_id or "binance"
self._ccxt_options = ccxt_options or {}

def _normalize_symbol(self, symbol: str) -> str:
"""Normalize symbol format for specific exchanges.

For Hyperliquid: converts BTC-USDC to BTC/USDC:USDC (swap format)
For other exchanges: converts BTC-USDC to BTC/USDC:USDC

Args:
symbol: Symbol in format 'BTC-USDC', 'ETH-USDT', etc.

Returns:
Normalized CCXT symbol for the specific exchange
"""
# Replace dash with slash
base_symbol = symbol.replace("-", "/")

# For most exchanges (especially those requiring settlement currency)
if ":" not in base_symbol:
parts = base_symbol.split("/")
if len(parts) == 2:
# Add settlement currency (e.g., BTC/USDC -> BTC/USDC:USDC)
base_symbol = f"{parts[0]}/{parts[1]}:{parts[1]}"

return base_symbol

async def get_recent_candles(
self, symbols: List[str], interval: str, lookback: int
) -> List[Candle]:
async def _fetch(symbol: str) -> List[List]:
async def _fetch(symbol: str, normalized_symbol: str) -> List[List]:
# instantiate exchange class by name (e.g., ccxtpro.kraken)
exchange_cls = get_exchange_cls(self._exchange_id)
exchange = exchange_cls({"newUpdates": False, **self._ccxt_options})
try:
# ccxt.pro uses async fetch_ohlcv
# ccxt.pro uses async fetch_ohlcv with normalized symbol
data = await exchange.fetch_ohlcv(
symbol, timeframe=interval, since=None, limit=lookback
normalized_symbol, timeframe=interval, since=None, limit=lookback
)
return data
finally:
Expand All @@ -52,7 +76,9 @@ async def _fetch(symbol: str) -> List[List]:
# Run fetch for each symbol sequentially
for symbol in symbols:
try:
raw = await _fetch(symbol)
# Normalize symbol format for the exchange (e.g., BTC-USDC -> BTC/USDC:USDC)
normalized_symbol = self._normalize_symbol(symbol)
raw = await _fetch(symbol, normalized_symbol)
# raw is list of [ts, open, high, low, close, volume]
for row in raw:
ts, open_v, high_v, low_v, close_v, vol = row
Expand All @@ -73,12 +99,17 @@ async def _fetch(symbol: str) -> List[List]:
)
)
except Exception as exc:
logger.error(
"Failed to fetch candles for {} from {}, return empty candles. Error: {}",
logger.warning(
"Failed to fetch candles for {} (normalized: {}) from {}, data interval is {}, return empty candles. Error: {}",
symbol,
normalized_symbol,
self._exchange_id,
interval,
exc,
)
logger.debug(
f"Fetch candles for {len(candles)} symbols: {symbols}, interval: {interval}, lookback: {lookback}"
)
return candles

async def get_market_snapshot(self, symbols: List[str]) -> MarketSnapShotType:
Expand Down Expand Up @@ -193,6 +224,7 @@ async def get_market_snapshot(self, symbols: List[str]) -> MarketSnapShotType:
symbol,
self._exchange_id,
)
logger.debug(f"Fetch market snapshot for {sym} data: {snapshot}")
except Exception:
logger.exception(
"Failed to fetch market snapshot for {} at {}",
Expand Down
Loading