diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..3f28c95e1 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,112 @@ +name: Deploy to GCP (Singapore) + +# Triggers on every push to main, or manually from the Actions tab. +on: + push: + branches: [main] + workflow_dispatch: + +# ── GitHub repository secrets (Settings → Secrets) ─────────────────────────── +# VM_IP – 34.142.253.116 (reserved static IP, whitelist in Binance) +# VM_SSH_PRIVATE_KEY – ed25519 private key for the ubuntu user on the VM +# VM_USER – SSH user (ubuntu) +# GHCR_OWNER – GitHub username that owns the package (lukecold) + +env: + IMAGE: ghcr.io/${{ secrets.GHCR_OWNER || github.repository_owner }}/valuecell + DEPLOY_DIR: /opt/valuecell + +jobs: + build-and-push: + name: Build & Push image + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write # push to GHCR + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push (linux/amd64 – e2-micro is x86) + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile.cloud + platforms: linux/amd64 + push: true + build-args: | + VITE_API_BASE_URL=/api/v1 + tags: | + ${{ env.IMAGE }}:latest + ${{ env.IMAGE }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + name: Deploy to GCE VM + needs: build-and-push + runs-on: ubuntu-latest + + steps: + - name: Checkout (for docker-compose.yml) + uses: actions/checkout@v4 + + - name: Copy docker-compose.yml to VM + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.VM_IP }} + username: ${{ secrets.VM_USER }} + key: ${{ secrets.VM_SSH_PRIVATE_KEY }} + source: cloud/docker-compose.yml + target: ${{ env.DEPLOY_DIR }} + strip_components: 1 + + - name: Pull new image and restart + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.VM_IP }} + username: ${{ secrets.VM_USER }} + key: ${{ secrets.VM_SSH_PRIVATE_KEY }} + script: | + set -euo pipefail + cd ${{ env.DEPLOY_DIR }} + mkdir -p data + + # Log in to GHCR so Docker can pull the image + echo "${{ secrets.GITHUB_TOKEN }}" | \ + docker login ghcr.io -u ${{ github.actor }} --password-stdin + + GHCR_OWNER=${{ secrets.GHCR_OWNER || github.repository_owner }} \ + docker compose pull + + GHCR_OWNER=${{ secrets.GHCR_OWNER || github.repository_owner }} \ + docker compose --env-file .env up -d --remove-orphans + + docker image prune -f + + sleep 5 + curl -sf http://localhost:8080/api/v1/healthz \ + && echo "✓ Health check passed" \ + || echo "✗ Health check failed – run: docker logs valuecell" + + - name: Summary + run: | + echo "### Deployed to GCP Singapore (asia-southeast1)" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**URL**: http://${{ secrets.VM_IP }}:8080" >> "$GITHUB_STEP_SUMMARY" + echo "**Fixed IP** (whitelist in Binance): \`${{ secrets.VM_IP }}\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/cloud/docker-compose.yml b/cloud/docker-compose.yml new file mode 100644 index 000000000..2b023b0df --- /dev/null +++ b/cloud/docker-compose.yml @@ -0,0 +1,45 @@ +# cloud/docker-compose.yml +# Runs on the Oracle Cloud free-tier VM (ap-singapore-1). +# Deploy directory on the VM: /opt/valuecell/ +# +# Usage on the VM: +# cd /opt/valuecell +# docker compose pull && docker compose up -d + +services: + app: + image: ghcr.io/${GHCR_OWNER:-lukecold}/valuecell:latest + container_name: valuecell + restart: always + ports: + - "8080:8080" + environment: + APP_ENVIRONMENT: production + API_DEBUG: "false" + # Tells FastAPI where the built React SPA lives inside the image. + FRONTEND_BUILD_DIR: /app/static + # SQLite file on the host volume – survives container restarts and upgrades. + VALUECELL_DATABASE_URL: "sqlite:////data/valuecell.db" + # ── AI provider keys ────────────────────────────────────────────────────── + # Set these in /opt/valuecell/.env on the VM (never commit real keys): + # echo "OPENAI_API_KEY=sk-..." >> /opt/valuecell/.env + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + GOOGLE_API_KEY: ${GOOGLE_API_KEY:-} + AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:-} + AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + volumes: + # Persistent data directory – SQLite DB lives here. + - /opt/valuecell/data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/healthz"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "20m" + max-file: "5" diff --git a/cloud/vm-setup.sh b/cloud/vm-setup.sh new file mode 100755 index 000000000..d7e94b4d1 --- /dev/null +++ b/cloud/vm-setup.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# ============================================================================= +# cloud/vm-setup.sh – One-time setup for the Oracle Cloud free-tier VM +# +# Run this ONCE after creating the OCI instance (as the default opc/ubuntu user): +# bash vm-setup.sh +# +# What it does: +# 1. Installs Docker + Docker Compose +# 2. Opens port 8080 in the VM's local iptables (OCI VCN Security List must +# also allow TCP 8080 – do this in the OCI Console) +# 3. Creates /opt/valuecell/{data,.env} with safe permissions +# 4. Logs in to GitHub Container Registry (GHCR) so Docker can pull the image +# 5. Pulls the image and starts the app via docker compose +# +# Before running: +# export GHCR_TOKEN= +# export GHCR_OWNER= +# ============================================================================= +set -euo pipefail + +GHCR_OWNER="${GHCR_OWNER:?Set GHCR_OWNER=}" +GHCR_TOKEN="${GHCR_TOKEN:?Set GHCR_TOKEN=}" +DEPLOY_DIR="/opt/valuecell" + +info() { echo " [INFO] $*"; } +ok() { echo " [ OK ] $*"; } + +# ── 1. System update + Docker ───────────────────────────────────────────────── +info "Updating system packages..." +sudo apt-get update -qq +sudo apt-get install -y -qq \ + ca-certificates curl gnupg lsb-release iptables-persistent + +info "Installing Docker..." +# Official Docker install (works on Ubuntu 22.04 / 24.04 and OL8/OL9) +if ! command -v docker &>/dev/null; then + curl -fsSL https://get.docker.com | sudo sh +fi + +sudo systemctl enable --now docker +sudo usermod -aG docker "$USER" +ok "Docker $(docker --version) ready" + +# Docker Compose v2 (plugin bundled with modern Docker, ensure it's available) +docker compose version &>/dev/null || sudo apt-get install -y docker-compose-plugin +ok "Docker Compose $(docker compose version --short) ready" + +# ── 2. Open port 8080 in iptables ───────────────────────────────────────────── +# OCI instances use iptables by default; the VCN Security List is a separate layer. +info "Opening port 8080 in iptables..." +if ! sudo iptables -C INPUT -p tcp --dport 8080 -j ACCEPT 2>/dev/null; then + sudo iptables -I INPUT 6 -m state --state NEW -p tcp --dport 8080 -j ACCEPT +fi +# Persist rules across reboots +sudo netfilter-persistent save +ok "Port 8080 open" + +# ── 3. App directory + env file ─────────────────────────────────────────────── +info "Creating $DEPLOY_DIR..." +sudo mkdir -p "${DEPLOY_DIR}/data" +sudo chown -R "$USER:$USER" "$DEPLOY_DIR" +chmod 700 "$DEPLOY_DIR" + +# Create a .env stub if it doesn't already exist +if [[ ! -f "${DEPLOY_DIR}/.env" ]]; then + cat > "${DEPLOY_DIR}/.env" <<'ENVEOF' +# /opt/valuecell/.env – loaded automatically by docker compose +# Add your AI provider keys here. This file is NOT committed to git. + +OPENAI_API_KEY= +OPENROUTER_API_KEY= +GOOGLE_API_KEY= +AZURE_OPENAI_API_KEY= +AZURE_OPENAI_ENDPOINT= +ANTHROPIC_API_KEY= +ENVEOF + chmod 600 "${DEPLOY_DIR}/.env" + ok "Created ${DEPLOY_DIR}/.env ← fill in your API keys" +else + ok "${DEPLOY_DIR}/.env already exists, skipping" +fi + +# Copy docker-compose.yml into the deploy dir +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cp "${SCRIPT_DIR}/docker-compose.yml" "${DEPLOY_DIR}/docker-compose.yml" +ok "Copied docker-compose.yml to $DEPLOY_DIR" + +# ── 4. Log in to GHCR and pull the image ───────────────────────────────────── +info "Logging in to ghcr.io..." +echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_OWNER" --password-stdin +ok "Logged in to ghcr.io" + +info "Pulling image ghcr.io/${GHCR_OWNER}/valuecell:latest ..." +GHCR_OWNER="$GHCR_OWNER" docker compose -f "${DEPLOY_DIR}/docker-compose.yml" pull +ok "Image pulled" + +# ── 5. Start the app ────────────────────────────────────────────────────────── +info "Starting valuecell..." +GHCR_OWNER="$GHCR_OWNER" \ + docker compose -f "${DEPLOY_DIR}/docker-compose.yml" \ + --env-file "${DEPLOY_DIR}/.env" \ + up -d +ok "valuecell is up" + +# ── Summary ─────────────────────────────────────────────────────────────────── +echo "" +echo "════════════════════════════════════════════════════════════════" +echo " Setup complete!" +echo "════════════════════════════════════════════════════════════════" +echo "" +PUBLIC_IP=$(curl -s --max-time 5 http://169.254.169.254/opc/v1/instance/networkInterfaces/ \ + 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[0]['publicIp'])" \ + 2>/dev/null || echo "") +echo " App URL : http://${PUBLIC_IP}:8080" +echo " Static IP: ${PUBLIC_IP} ← whitelist this in Binance" +echo "" +echo " To view logs : docker logs -f valuecell" +echo " To restart : docker compose -f /opt/valuecell/docker-compose.yml restart" +echo " To update : docker compose -f /opt/valuecell/docker-compose.yml pull && \\" +echo " docker compose -f /opt/valuecell/docker-compose.yml up -d" +echo "" +echo " IMPORTANT: In the OCI Console → Networking → VCN → Security Lists," +echo " add an Ingress Rule for TCP port 8080 from 0.0.0.0/0" +echo "" +echo " Don't forget to fill in your API keys:" +echo " nano /opt/valuecell/.env" +echo " docker compose -f /opt/valuecell/docker-compose.yml up -d" +echo "" diff --git a/docker/Dockerfile.cloud b/docker/Dockerfile.cloud new file mode 100644 index 000000000..5629b5d9c --- /dev/null +++ b/docker/Dockerfile.cloud @@ -0,0 +1,52 @@ +# ─── Stage 1: Build the React SPA ───────────────────────────────────────────── +# Output: build/client/ (static files only – ssr:false in react-router.config.ts) +FROM oven/bun:1.3.3 AS frontend-build + +WORKDIR /build + +# Install deps first for better layer caching +COPY frontend/package.json frontend/bun.lock ./ +RUN bun install --frozen-lockfile + +COPY frontend/ ./ + +# VITE_API_BASE_URL is baked in at build time. +# Using a relative path means the SPA will call the same origin, +# so no CORS issues and no need to know the Cloud Run URL ahead of time. +ARG VITE_API_BASE_URL=/api/v1 +ENV VITE_API_BASE_URL=$VITE_API_BASE_URL + +RUN bun run build + +# ─── Stage 2: Python backend + bundled frontend ──────────────────────────────── +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +WORKDIR /app + +# Copy Python project manifests first to get a cached dependency layer. +COPY python/pyproject.toml python/uv.lock ./ + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-install-project + +# Copy the full Python source tree. +COPY python/ ./ + +# Install the project itself. +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked + +# Bundle the built frontend into the container. +# FastAPI will serve these as static files (see FRONTEND_BUILD_DIR below). +COPY --from=frontend-build /build/build/client /app/static + +# ─── Runtime configuration ───────────────────────────────────────────────────── +ENV APP_ENVIRONMENT=production +ENV API_DEBUG=false +# Tell the FastAPI app where to find the built frontend. +ENV FRONTEND_BUILD_DIR=/app/static +# Cloud Run injects PORT (default 8080). We skip main.py's stdin control-loop +# by calling uvicorn directly, which is also slightly faster to start. +EXPOSE 8080 + +CMD ["sh", "-c", "exec uv run uvicorn valuecell.server.api.app:app --host 0.0.0.0 --port ${PORT:-8080}"] diff --git a/frontend/biome.json b/frontend/biome.json index af0c2fafd..30f098ac4 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.3/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", "files": { "includes": [ "src/**/*.{ts,tsx,js,jsx}", @@ -53,7 +53,6 @@ "useExhaustiveDependencies": "warn" }, "nursery": { - "noImportCycles": "off", "useSortedClasses": { "fix": "safe", "level": "error", diff --git a/frontend/src/constants/agent.ts b/frontend/src/constants/agent.ts index 8415efebd..09c1ef384 100644 --- a/frontend/src/constants/agent.ts +++ b/frontend/src/constants/agent.ts @@ -114,9 +114,8 @@ export const VALUECELL_AGENT: AgentInfo = { // Trading symbols options export const TRADING_SYMBOLS: string[] = [ - "BTC/USDT", + "BNB/USDT", "ETH/USDT", "SOL/USDT", - "DOGE/USDT", "XRP/USDT", ]; diff --git a/python/valuecell/agents/common/trading/_internal/coordinator.py b/python/valuecell/agents/common/trading/_internal/coordinator.py index 40f04ccec..c934077fa 100644 --- a/python/valuecell/agents/common/trading/_internal/coordinator.py +++ b/python/valuecell/agents/common/trading/_internal/coordinator.py @@ -179,6 +179,7 @@ async def run_once(self) -> DecisionCycleResult: compose_result = await self._composer.compose(context) instructions = compose_result.instructions rationale = compose_result.rationale + stop_prices = compose_result.stop_prices logger.info(f"🔍 Composer returned {len(instructions)} instructions") for idx, inst in enumerate(instructions): logger.info( @@ -229,6 +230,7 @@ async def run_once(self) -> DecisionCycleResult: trades = self._create_trades(tx_results, compose_id, timestamp_ms) self.portfolio_service.apply_trades(trades, market_features) + self.portfolio_service.update_stop_prices(stop_prices) summary = self.build_summary(timestamp_ms, trades) history_records = self._create_history_records( @@ -480,6 +482,7 @@ def build_summary( # 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) + stop_prices = view.stop_prices except Exception: # Fallback to internal tracking if portfolio service is unavailable unrealized = float(self._unrealized_pnl or 0.0) @@ -489,6 +492,7 @@ def build_summary( if self._request.trading_config.initial_capital is not None else 0.0 ) + stop_prices = {} # Keep internal state in sync (allow negative unrealized PnL) self._unrealized_pnl = float(unrealized) @@ -513,6 +517,7 @@ def build_summary( unrealized_pnl_pct=unrealized_pnl_pct, pnl_pct=pnl_pct, total_value=equity, + stop_prices=stop_prices, last_updated_ts=timestamp_ms, ) diff --git a/python/valuecell/agents/common/trading/_internal/runtime.py b/python/valuecell/agents/common/trading/_internal/runtime.py index 515fb1ab0..e9574a605 100644 --- a/python/valuecell/agents/common/trading/_internal/runtime.py +++ b/python/valuecell/agents/common/trading/_internal/runtime.py @@ -15,7 +15,13 @@ InMemoryHistoryRecorder, RollingDigestBuilder, ) -from ..models import Constraints, DecisionCycleResult, TradingMode, UserRequest +from ..models import ( + Constraints, + DecisionCycleResult, + StopPrice, + TradingMode, + UserRequest, +) from ..portfolio.in_memory import InMemoryPortfolioService from ..utils import fetch_free_cash_from_gateway, fetch_positions_from_gateway from .coordinator import DefaultDecisionCoordinator @@ -122,6 +128,7 @@ async def create_strategy_runtime( # so the in-memory portfolio starts with the previously recorded equity. free_cash_override = None total_cash_override = None + stop_prices = {} if strategy_id_override: try: repo = get_strategy_repository() @@ -140,6 +147,19 @@ async def create_strategy_runtime( "Initialized runtime initial capital from persisted snapshot for strategy_id=%s", strategy_id_override, ) + stop_prices = {} + strategy = repo.get_strategy_by_strategy_id(strategy_id_override) + if strategy and strategy.strategy_metadata: + raw_stops = strategy.strategy_metadata.get("stop_prices", {}) + stop_prices = { + symbol: StopPrice.model_validate(data) + for symbol, data in raw_stops.items() + } + logger.info( + "Initialized runtime stop prices {} from persisted snapshot for strategy_id {}", + stop_prices, + strategy_id_override, + ) except Exception: logger.exception( "Failed to initialize initial capital from persisted snapshot for strategy_id=%s", @@ -160,6 +180,7 @@ async def create_strategy_runtime( market_type=request.exchange_config.market_type, constraints=constraints, strategy_id=strategy_id, + stop_prices=stop_prices, ) # Use custom composer if provided, otherwise default to LlmComposer diff --git a/python/valuecell/agents/common/trading/decision/prompt_based/composer.py b/python/valuecell/agents/common/trading/decision/prompt_based/composer.py index dee1216ea..9deddbdc2 100644 --- a/python/valuecell/agents/common/trading/decision/prompt_based/composer.py +++ b/python/valuecell/agents/common/trading/decision/prompt_based/composer.py @@ -100,7 +100,11 @@ async def compose(self, context: ComposeContext) -> ComposeResult: context.compose_id, plan.rationale, ) - return ComposeResult(instructions=[], rationale=plan.rationale) + return ComposeResult( + instructions=[], + rationale=plan.rationale, + stop_prices=plan.stop_prices, + ) except Exception as exc: # noqa: BLE001 logger.error("LLM invocation failed: {}", exc) return ComposeResult( @@ -114,7 +118,11 @@ async def compose(self, context: ComposeContext) -> ComposeResult: logger.error("Failed sending plan to Discord: {}", exc) normalized = self._normalize_plan(context, plan) - return ComposeResult(instructions=normalized, rationale=plan.rationale) + return ComposeResult( + instructions=normalized, + rationale=plan.rationale, + stop_prices=plan.stop_prices, + ) # ------------------------------------------------------------------ @@ -153,16 +161,21 @@ def _build_llm_prompt(self, context: ComposeContext) -> str: market = extract_market_section(features.get("market_snapshot", [])) # Portfolio positions - positions = [ - { - "symbol": sym, + positions = { + sym: { + "avg_price": snap.avg_price, "qty": float(snap.quantity), "unrealized_pnl": snap.unrealized_pnl, "entry_ts": snap.entry_ts, } for sym, snap in pv.positions.items() if abs(float(snap.quantity)) > 0 - ] + } + for symbol, stop_price in pv.stop_prices.items(): + if symbol not in positions: + continue + positions[symbol]["stop_gain_price"] = stop_price.stop_gain_price + positions[symbol]["stop_loss_price"] = stop_price.stop_loss_price # Constraints constraints = ( @@ -203,6 +216,7 @@ async def _call_llm(self, prompt: str) -> TradePlanProposal: agent's `response.content` is returned (or validated) as a `LlmPlanProposal`. """ + logger.debug("LLM prompt {}", prompt) response = await asyncio.wait_for( self.agent.arun(prompt), timeout=self._max_llm_wait_time_sec ) @@ -245,6 +259,13 @@ async def _send_plan_to_discord(self, plan: TradePlanProposal) -> None: if top_r: parts.append("**Overall rationale:**\n") parts.append(f"{top_r}\n") + if len(plan.stop_prices) > 0: + parts.append("**Updated stop prices:**") + for symbol, stop_price in plan.stop_prices.items(): + parts.append( + f"{symbol}\tstop gain: {stop_price.stop_gain_price}\tstop loss: {stop_price.stop_loss_price}" + ) + parts.append("") parts.append("**Items:**\n") for it in actionable: diff --git a/python/valuecell/agents/common/trading/decision/prompt_based/system_prompt.py b/python/valuecell/agents/common/trading/decision/prompt_based/system_prompt.py index bd0532924..3a6b6aff3 100644 --- a/python/valuecell/agents/common/trading/decision/prompt_based/system_prompt.py +++ b/python/valuecell/agents/common/trading/decision/prompt_based/system_prompt.py @@ -18,6 +18,7 @@ - For derivatives (one-way positions): opening on the opposite side implies first flattening to 0 then opening the requested side; the executor handles this split. - For spot: only open_long/close_long are valid; open_short/close_short will be treated as reducing toward 0 or ignored. - One item per symbol at most. No hedging (never propose both long and short exposure on the same symbol). +- Upon the market price closes above the nearest minor resistance level, move the stop loss to the break-even point (entry price + costs) to eliminate the risk of loss on the trade. After the stop has been moved to break-even, implement a trailing stop to protect any further accumulated profit. CONSTRAINTS & VALIDATION - Respect max_positions, max_leverage, max_position_qty, quantity_step, min_trade_qty, max_order_qty, min_notional, and available buying power. @@ -32,11 +33,13 @@ - Prefer fewer, higher-quality actions; choose noop when edge is weak. - Consider existing position entry times when deciding new actions. Use each position's `entry_ts` (entry timestamp) as a signal: avoid opening, flipping, or repeatedly scaling the same instrument shortly after its entry unless the new signal is strong (confidence near 1.0) and constraints allow it. - Treat recent entries as a deterrent to new opens to reduce churn — do not re-enter or flip a position within a short holding window unless there is a clear, high-confidence reason. This rule supplements Sharpe-based and other risk heuristics to prevent overtrading. +- Respect the stop prices - do not close position if stop prices are not hit OUTPUT & EXPLANATION - Always include a brief top-level rationale summarizing your decision basis. - Your rationale must transparently reveal your thinking process (signals evaluated, thresholds, trade-offs) and the operational steps (how sizing is derived, which constraints/normalization will be applied). - If no actions are emitted (noop), your rationale must explain specific reasons: reference current prices and price.change_pct relative to your thresholds, and note any constraints or risk flags that caused noop. +- For open_long and open_short actions, always include stop loss and stop gain prices for the symbol. MARKET FEATURES The Context includes `features.market_snapshot`: a compact, per-cycle bundle of references derived from the latest exchange snapshot. Each item corresponds to a tradable symbol and may include: diff --git a/python/valuecell/agents/common/trading/models.py b/python/valuecell/agents/common/trading/models.py index e89fca469..f10a5ea8f 100644 --- a/python/valuecell/agents/common/trading/models.py +++ b/python/valuecell/agents/common/trading/models.py @@ -550,6 +550,10 @@ class PortfolioView(BaseModel): " effective leverage if available, otherwise falls back to constraints.max_leverage." ), ) + stop_prices: Dict[str, "StopPrice"] = Field( + default_factory=dict, + description="Dictionary of stop prices for existing positions and positions to open.", + ) class TradeDecisionAction(str, Enum): @@ -587,6 +591,17 @@ def derive_side_from_action( return None +class StopPrice(BaseModel): + stop_gain_price: Optional[float] = Field( + ..., + description="Stop gain price for this position.", + ) + stop_loss_price: Optional[float] = Field( + ..., + description="Stop loss price for this position.", + ) + + class TradeDecisionItem(BaseModel): """Trade plan item. Interprets target_qty as operation size (magnitude). @@ -641,6 +656,10 @@ class TradePlanProposal(BaseModel): rationale: Optional[str] = Field( default=None, description="Optional natural language rationale" ) + stop_prices: Dict[str, StopPrice] = Field( + default_factory=dict, + description="Map of ticker symbols to their respective stop prices", + ) class PriceMode(str, Enum): @@ -911,6 +930,10 @@ class StrategySummary(BaseModel): default=None, description="Total portfolio value (equity) including cash and positions", ) + stop_prices: Dict[str, StopPrice] = Field( + default_factory=dict, + description="Map of ticker symbols to their respective stop prices", + ) last_updated_ts: Optional[int] = Field(default=None) @@ -934,6 +957,7 @@ class ComposeResult(BaseModel): instructions: List[TradeInstruction] rationale: Optional[str] = None + stop_prices: Dict[str, StopPrice] = {} class FeaturesPipelineResult(BaseModel): diff --git a/python/valuecell/agents/common/trading/portfolio/in_memory.py b/python/valuecell/agents/common/trading/portfolio/in_memory.py index 857bb8d18..4d4cac4c2 100644 --- a/python/valuecell/agents/common/trading/portfolio/in_memory.py +++ b/python/valuecell/agents/common/trading/portfolio/in_memory.py @@ -7,6 +7,7 @@ MarketType, PortfolioView, PositionSnapshot, + StopPrice, TradeHistoryEntry, TradeSide, TradeType, @@ -41,6 +42,7 @@ def __init__( initial_positions: Dict[str, PositionSnapshot], trading_mode: TradingMode, market_type: MarketType, + stop_prices: Dict[str, StopPrice], constraints: Optional[Constraints] = None, strategy_id: Optional[str] = None, ) -> None: @@ -75,6 +77,7 @@ def __init__( total_realized_pnl=0.0, buying_power=free_cash, free_cash=free_cash, + stop_prices=stop_prices, ) self._trading_mode = trading_mode self._market_type = market_type @@ -89,6 +92,16 @@ def get_view(self) -> PortfolioView: pass return self._view + def update_stop_prices(self, stop_prices: Dict[str, StopPrice]) -> None: + for symbol, new_stop in stop_prices.items(): + existing = self._view.stop_prices.get(symbol) + if existing: + update_data = new_stop.model_dump(exclude_unset=True, exclude_none=True) + for key, value in update_data.items(): + setattr(existing, key, value) + else: + self._view.stop_prices[symbol] = new_stop + def apply_trades( self, trades: List[TradeHistoryEntry], market_features: List[FeatureVector] ) -> None: diff --git a/python/valuecell/agents/common/trading/portfolio/interfaces.py b/python/valuecell/agents/common/trading/portfolio/interfaces.py index 3471ef4c4..544944a87 100644 --- a/python/valuecell/agents/common/trading/portfolio/interfaces.py +++ b/python/valuecell/agents/common/trading/portfolio/interfaces.py @@ -1,11 +1,12 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import List, Optional +from typing import Dict, List, Optional from valuecell.agents.common.trading.models import ( FeatureVector, PortfolioView, + StopPrice, TradeHistoryEntry, ) @@ -34,6 +35,17 @@ def apply_trades( """ raise NotImplementedError + def update_stop_prices(self, stop_prices: Dict[str, StopPrice]) -> None: + """Update the stop prices to the portfolio view. + + Implementations that support state changes (paper trading, backtests) + should update their internal view accordingly. `stop_prices` + a vector of stop (gain/loss) prices for each symbol. This method + is optional for read-only portfolio services, but providing it here + makes the contract explicit to callers. + """ + raise NotImplementedError + class BasePortfolioSnapshotStore(ABC): """Persist/load portfolio snapshots (optional for paper/backtest modes).""" diff --git a/python/valuecell/server/api/app.py b/python/valuecell/server/api/app.py index a35523d4a..74fdf634b 100644 --- a/python/valuecell/server/api/app.py +++ b/python/valuecell/server/api/app.py @@ -7,6 +7,8 @@ from fastapi import FastAPI from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles from loguru import logger from ...adapters.assets import get_adapter_manager @@ -190,17 +192,37 @@ def _add_exception_handlers(app: FastAPI) -> None: def _add_routes(app: FastAPI, settings) -> None: """Add routes to the application.""" - # Root endpoint - @app.get("/", response_model=SuccessResponse[AppInfoData]) - async def home_page(): - return SuccessResponse.create( - data=AppInfoData( - name=settings.APP_NAME, - version=settings.APP_VERSION, - environment=settings.APP_ENVIRONMENT, - ), - msg="Welcome to ValueCell Server API", - ) + # Detect whether we should serve the built React SPA. + # Set FRONTEND_BUILD_DIR to the path of the built client files (e.g. /app/static). + _frontend_dir_str = os.getenv("FRONTEND_BUILD_DIR", "") + _frontend_path: Path | None = Path(_frontend_dir_str) if _frontend_dir_str else None + _serve_frontend = bool( + _frontend_path and _frontend_path.is_dir() and (_frontend_path / "index.html").exists() + ) + + if _serve_frontend: + # In web-serving mode the root shows the React SPA, not the API info page. + @app.get("/", include_in_schema=False) + async def serve_index(): + return FileResponse(str(_frontend_path / "index.html")) + + # Mount the assets sub-directory for efficient static-file serving + # (hashed filenames → long cache TTL is safe). + _assets_dir = _frontend_path / "assets" + if _assets_dir.is_dir(): + app.mount("/assets", StaticFiles(directory=str(_assets_dir)), name="assets") + else: + # API-only mode (local desktop usage): expose API info at the root. + @app.get("/", response_model=SuccessResponse[AppInfoData]) + async def home_page(): + return SuccessResponse.create( + data=AppInfoData( + name=settings.APP_NAME, + version=settings.APP_VERSION, + environment=settings.APP_ENVIRONMENT, + ), + msg="Welcome to ValueCell Server API", + ) @app.get(f"{API_PREFIX}/healthz", response_model=SuccessResponse) async def health_check(): @@ -236,6 +258,16 @@ async def health_check(): # Include task router app.include_router(create_task_router(), prefix=API_PREFIX) + if _serve_frontend: + # SPA catch-all: must be registered AFTER all API routes. + # Serves static files verbatim; falls back to index.html for client-side routes. + @app.get("/{full_path:path}", include_in_schema=False) + async def serve_spa(full_path: str): + file_path = _frontend_path / full_path + if file_path.is_file(): + return FileResponse(str(file_path)) + return FileResponse(str(_frontend_path / "index.html")) + # For uvicorn app = create_app()