From 466d5e4c85992622cc12ac63513a2b96bde89d6e Mon Sep 17 00:00:00 2001 From: Kelu Date: Mon, 15 Dec 2025 15:01:14 +0700 Subject: [PATCH 1/3] feat: Maintain Stop Gain/Loss Prices Across LLM Decisions This PR implements robust management for Stop Gain/Loss prices, ensuring these critical protective limits are **maintained and consistently applied** across trading sessions and subsequent LLM decisions. * **LLM Definition:** LLM response extended to include new Stop Gain/Loss price fields. * **Persistent Storage:** Defined stop prices are stored in session memory and persisted via database snapshot. * **Session Continuity:** Loads persisted stop prices when resuming a session, restoring trading strategy limits. * **Informed Decisions:** Existing stop prices are fed back into the LLM context to inform and assist subsequent trading actions. --- .../common/trading/_internal/coordinator.py | 3 + .../common/trading/_internal/runtime.py | 23 ++++- .../trading/_internal/stream_controller.py | 6 ++ .../trading/decision/prompt_based/composer.py | 27 +++++- .../decision/prompt_based/system_prompt.py | 3 + .../valuecell/agents/common/trading/models.py | 22 +++++ .../common/trading/portfolio/in_memory.py | 20 ++++ .../common/trading/portfolio/interfaces.py | 12 +++ python/valuecell/server/db/models/__init__.py | 2 + .../server/db/models/strategy_stop_price.py | 94 +++++++++++++++++++ .../db/repositories/strategy_repository.py | 70 ++++++++++++++ .../server/services/strategy_persistence.py | 18 +++- 12 files changed, 293 insertions(+), 7 deletions(-) create mode 100644 python/valuecell/server/db/models/strategy_stop_price.py diff --git a/python/valuecell/agents/common/trading/_internal/coordinator.py b/python/valuecell/agents/common/trading/_internal/coordinator.py index 40f04ccec..5fc555404 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( @@ -253,6 +255,7 @@ async def run_once(self) -> DecisionCycleResult: history_records=history_records, digest=digest, portfolio_view=portfolio, + stop_prices=stop_prices, ) def _create_trades( diff --git a/python/valuecell/agents/common/trading/_internal/runtime.py b/python/valuecell/agents/common/trading/_internal/runtime.py index 515fb1ab0..3950b8fc4 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 = { + stop_price.symbol: StopPrice( + symbol=stop_price.symbol, + stop_gain_price=stop_price.stop_gain_price, + stop_loss_price=stop_price.stop_loss_price, + ) + for stop_price in repo.get_stop_prices(strategy_id_override) + } + 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/_internal/stream_controller.py b/python/valuecell/agents/common/trading/_internal/stream_controller.py index 5f692fcdf..d9aa5dc36 100644 --- a/python/valuecell/agents/common/trading/_internal/stream_controller.py +++ b/python/valuecell/agents/common/trading/_internal/stream_controller.py @@ -271,6 +271,12 @@ def persist_cycle_results(self, result: DecisionCycleResult) -> None: "Persisted portfolio view for strategy={}", self.strategy_id ) + ok = strategy_persistence.persist_stop_prices( + self.strategy_id, result.stop_prices + ) + if ok: + logger.info("Persisted stop prices for strategy={}", self.strategy_id) + ok = strategy_persistence.persist_strategy_summary(result.strategy_summary) if ok: logger.info( 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..f00f4add5 100644 --- a/python/valuecell/agents/common/trading/decision/prompt_based/composer.py +++ b/python/valuecell/agents/common/trading/decision/prompt_based/composer.py @@ -114,7 +114,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 +157,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 +212,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 +255,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 stop_price in plan.stop_prices: + parts.append( + f"{stop_price.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..c2268a6f4 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=list, + description="List of stop prices for existing positions and positions to open.", + ) class TradeDecisionAction(str, Enum): @@ -587,6 +591,18 @@ def derive_side_from_action( return None +class StopPrice(BaseModel): + symbol: str = Field(..., description="Exchange symbol, e.g., BTC/USDT") + 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 +657,10 @@ class TradePlanProposal(BaseModel): rationale: Optional[str] = Field( default=None, description="Optional natural language rationale" ) + stop_prices: List[StopPrice] = Field( + default_factory=list, + description="List of stop prices for existing positions and positions to open.", + ) class PriceMode(str, Enum): @@ -934,6 +954,7 @@ class ComposeResult(BaseModel): instructions: List[TradeInstruction] rationale: Optional[str] = None + stop_prices: List[StopPrice] = [] class FeaturesPipelineResult(BaseModel): @@ -956,3 +977,4 @@ class DecisionCycleResult: history_records: List[HistoryRecord] digest: TradeDigest portfolio_view: PortfolioView + stop_prices: List[StopPrice] diff --git a/python/valuecell/agents/common/trading/portfolio/in_memory.py b/python/valuecell/agents/common/trading/portfolio/in_memory.py index 857bb8d18..7b8bfa9f7 100644 --- a/python/valuecell/agents/common/trading/portfolio/in_memory.py +++ b/python/valuecell/agents/common/trading/portfolio/in_memory.py @@ -7,12 +7,14 @@ MarketType, PortfolioView, PositionSnapshot, + StopPrice, TradeHistoryEntry, TradeSide, TradeType, TradingMode, ) from valuecell.agents.common.trading.utils import extract_price_map +from valuecell.server.db.models import StrategyStopPrices from .interfaces import BasePortfolioService @@ -41,6 +43,7 @@ def __init__( initial_positions: Dict[str, PositionSnapshot], trading_mode: TradingMode, market_type: MarketType, + stop_prices: Dict[str, StrategyStopPrices], constraints: Optional[Constraints] = None, strategy_id: Optional[str] = None, ) -> None: @@ -75,6 +78,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 +93,22 @@ def get_view(self) -> PortfolioView: pass return self._view + def update_stop_prices(self, stop_prices: List[StopPrice]) -> None: + for stop_price in stop_prices: + if stop_price.symbol in self._view.stop_prices: + self._view.stop_prices[stop_price.symbol].stop_gain_price = ( + stop_price.stop_gain_price + if stop_price.stop_gain_price is not None + else self._view.stop_prices[stop_price.symbol].stop_gain_price + ) + self._view.stop_prices[stop_price.symbol].stop_loss_price = ( + stop_price.stop_loss_price + if stop_price.stop_loss_price is not None + else self._view.stop_prices[stop_price.symbol].stop_loss_price + ) + else: + self._view.stop_prices[stop_price.symbol] = stop_price + 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..669a5d0bb 100644 --- a/python/valuecell/agents/common/trading/portfolio/interfaces.py +++ b/python/valuecell/agents/common/trading/portfolio/interfaces.py @@ -6,6 +6,7 @@ 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: List[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/db/models/__init__.py b/python/valuecell/server/db/models/__init__.py index e8d8688e9..c9885b864 100644 --- a/python/valuecell/server/db/models/__init__.py +++ b/python/valuecell/server/db/models/__init__.py @@ -17,6 +17,7 @@ from .strategy_holding import StrategyHolding from .strategy_instruction import StrategyInstruction from .strategy_portfolio import StrategyPortfolioView +from .strategy_stop_price import StrategyStopPrices from .user_profile import ProfileCategory, UserProfile from .watchlist import Watchlist, WatchlistItem @@ -35,4 +36,5 @@ "StrategyPortfolioView", "StrategyComposeCycle", "StrategyInstruction", + "StrategyStopPrices", ] diff --git a/python/valuecell/server/db/models/strategy_stop_price.py b/python/valuecell/server/db/models/strategy_stop_price.py new file mode 100644 index 000000000..022cafafb --- /dev/null +++ b/python/valuecell/server/db/models/strategy_stop_price.py @@ -0,0 +1,94 @@ +""" +ValueCell Server - Strategy Stop Prices Model + +This module defines the database model for strategy stop price records. +Each row represents a stop gain & loss info associated with a strategy and symbol. +""" + +from typing import Any, Dict + +from sqlalchemy import ( + Column, + DateTime, + ForeignKey, + Integer, + Numeric, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.sql import func + +from .base import Base + + +class StrategyStopPrices(Base): + """Strategy detail record for trades/positions associated with a strategy.""" + + __tablename__ = "strategy_stop_prices" + + # Primary key + id = Column(Integer, primary_key=True, index=True) + + # Foreign key to strategies (uses unique strategy_id) + strategy_id = Column( + String(100), + ForeignKey("strategies.strategy_id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="Runtime strategy identifier", + ) + + # Instrument and trade info + symbol = Column(String(50), nullable=False, index=True, comment="Instrument symbol") + stop_gain_price = Column( + Numeric(20, 8), nullable=True, comment="Price to stop gain" + ) + stop_loss_price = Column( + Numeric(20, 8), nullable=True, comment="Price to stop loss" + ) + + # Notes + note = Column(Text, nullable=True, comment="Optional note") + + # Timestamps + created_at = Column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Uniqueness: strategy_id + trade_id must be unique + __table_args__ = ( + UniqueConstraint("strategy_id", "symbol", name="uq_strategy_id_symbol"), + ) + + def __repr__(self) -> str: + return ( + f"" + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "strategy_id": self.strategy_id, + "symbol": self.symbol, + "stop_gain_price": ( + float(self.stop_gain_price) + if self.stop_gain_price is not None + else None + ), + "stop_loss_price": ( + float(self.stop_loss_price) + if self.stop_loss_price is not None + else None + ), + "note": self.note, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } diff --git a/python/valuecell/server/db/repositories/strategy_repository.py b/python/valuecell/server/db/repositories/strategy_repository.py index 861913ade..9b54865a4 100644 --- a/python/valuecell/server/db/repositories/strategy_repository.py +++ b/python/valuecell/server/db/repositories/strategy_repository.py @@ -11,6 +11,7 @@ from sqlalchemy import asc, desc, func from sqlalchemy.orm import Session +from ....agents.common.trading.models import StopPrice from ..connection import get_database_manager from ..models.strategy import Strategy from ..models.strategy_compose_cycle import StrategyComposeCycle @@ -19,6 +20,7 @@ from ..models.strategy_instruction import StrategyInstruction from ..models.strategy_portfolio import StrategyPortfolioView from ..models.strategy_prompt import StrategyPrompt +from ..models.strategy_stop_price import StrategyStopPrices class StrategyRepository: @@ -445,6 +447,59 @@ def add_instruction( if not self.db_session: session.close() + # Stop price operations + def upsert_stop_price( + self, + strategy_id: str, + stop_prices: List[StopPrice], + note: Optional[str] = None, + ) -> List[StrategyStopPrices]: + """Insert one strategy detail record.""" + session = self._get_session() + upserted_items = [] + try: + for stop_price in stop_prices: + existing_item = ( + session.query(StrategyStopPrices) + .filter( + StrategyStopPrices.strategy_id == strategy_id, + StrategyStopPrices.symbol == stop_price.symbol, + ) + .one_or_none() + ) + + if existing_item: + existing_item.stop_gain_price = stop_price.stop_gain_price + existing_item.stop_loss_price = stop_price.stop_loss_price + existing_item.note = note + item_to_return = existing_item + else: + new_item = StrategyStopPrices( + strategy_id=strategy_id, + symbol=stop_price.symbol, + stop_gain_price=stop_price.stop_gain_price, + stop_loss_price=stop_price.stop_loss_price, + note=note, + ) + session.add(new_item) + item_to_return = new_item + + session.add(item_to_return) + upserted_items.append(item_to_return) + + session.commit() + for item in upserted_items: + session.refresh(item) + session.expunge(item) + except Exception: + session.rollback() + return [] + finally: + if not self.db_session: + session.close() + + return upserted_items + def get_cycles( self, strategy_id: str, limit: Optional[int] = None ) -> List[StrategyComposeCycle]: @@ -530,6 +585,21 @@ def get_details( if not self.db_session: session.close() + def get_stop_prices(self, strategy_id: str) -> List[StrategyStopPrices]: + """Get detail records for a strategy ordered by event_time desc.""" + session = self._get_session() + try: + query = session.query(StrategyStopPrices).filter( + StrategyDetail.strategy_id == strategy_id + ) + items = query.all() + for item in items: + session.expunge(item) + return items + finally: + if not self.db_session: + session.close() + # Prompts operations (kept under strategy namespace) def list_prompts(self) -> List[StrategyPrompt]: """Return all prompts ordered by updated_at desc.""" diff --git a/python/valuecell/server/services/strategy_persistence.py b/python/valuecell/server/services/strategy_persistence.py index 0526bca90..d8fd096d2 100644 --- a/python/valuecell/server/services/strategy_persistence.py +++ b/python/valuecell/server/services/strategy_persistence.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from typing import Optional +from typing import List, Optional from loguru import logger @@ -225,6 +225,22 @@ def persist_portfolio_view(view: agent_models.PortfolioView) -> bool: return False +def persist_stop_prices( + strategy_id: str, stop_prices: List[agent_models.StopPrice] +) -> bool: + """Persist a StrategySummary into the Strategy.strategy_metadata JSON. + + Returns True on success, False on failure. + """ + repo = get_strategy_repository() + try: + updated = repo.upsert_stop_price(strategy_id, stop_prices=stop_prices) + return updated is not None + except Exception as e: + logger.exception("persist_stop_prices failed for {}, error: {}", strategy_id, e) + return False + + def persist_strategy_summary(summary: agent_models.StrategySummary) -> bool: """Persist a StrategySummary into the Strategy.strategy_metadata JSON. From 887302ee81841f2164f5aefe457d6b26fd0fe21c Mon Sep 17 00:00:00 2001 From: Kelu Date: Wed, 17 Dec 2025 23:11:15 +0700 Subject: [PATCH 2/3] feat: Move stop prices into strategy metadata --- frontend/biome.json | 3 +- frontend/src/constants/agent.ts | 3 +- .../common/trading/_internal/coordinator.py | 4 +- .../common/trading/_internal/runtime.py | 16 ++-- .../trading/_internal/stream_controller.py | 6 -- .../trading/decision/prompt_based/composer.py | 10 +- .../valuecell/agents/common/trading/models.py | 18 ++-- .../common/trading/portfolio/in_memory.py | 25 ++--- .../common/trading/portfolio/interfaces.py | 4 +- python/valuecell/server/db/models/__init__.py | 2 - .../server/db/models/strategy_stop_price.py | 94 ------------------- .../db/repositories/strategy_repository.py | 70 -------------- .../server/services/strategy_persistence.py | 18 +--- 13 files changed, 42 insertions(+), 231 deletions(-) delete mode 100644 python/valuecell/server/db/models/strategy_stop_price.py 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 5fc555404..c934077fa 100644 --- a/python/valuecell/agents/common/trading/_internal/coordinator.py +++ b/python/valuecell/agents/common/trading/_internal/coordinator.py @@ -255,7 +255,6 @@ async def run_once(self) -> DecisionCycleResult: history_records=history_records, digest=digest, portfolio_view=portfolio, - stop_prices=stop_prices, ) def _create_trades( @@ -483,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) @@ -492,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) @@ -516,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 3950b8fc4..e9574a605 100644 --- a/python/valuecell/agents/common/trading/_internal/runtime.py +++ b/python/valuecell/agents/common/trading/_internal/runtime.py @@ -147,14 +147,14 @@ async def create_strategy_runtime( "Initialized runtime initial capital from persisted snapshot for strategy_id=%s", strategy_id_override, ) - stop_prices = { - stop_price.symbol: StopPrice( - symbol=stop_price.symbol, - stop_gain_price=stop_price.stop_gain_price, - stop_loss_price=stop_price.stop_loss_price, - ) - for stop_price in repo.get_stop_prices(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, diff --git a/python/valuecell/agents/common/trading/_internal/stream_controller.py b/python/valuecell/agents/common/trading/_internal/stream_controller.py index d9aa5dc36..5f692fcdf 100644 --- a/python/valuecell/agents/common/trading/_internal/stream_controller.py +++ b/python/valuecell/agents/common/trading/_internal/stream_controller.py @@ -271,12 +271,6 @@ def persist_cycle_results(self, result: DecisionCycleResult) -> None: "Persisted portfolio view for strategy={}", self.strategy_id ) - ok = strategy_persistence.persist_stop_prices( - self.strategy_id, result.stop_prices - ) - if ok: - logger.info("Persisted stop prices for strategy={}", self.strategy_id) - ok = strategy_persistence.persist_strategy_summary(result.strategy_summary) if ok: logger.info( 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 f00f4add5..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( @@ -257,9 +261,9 @@ async def _send_plan_to_discord(self, plan: TradePlanProposal) -> None: parts.append(f"{top_r}\n") if len(plan.stop_prices) > 0: parts.append("**Updated stop prices:**") - for stop_price in plan.stop_prices: + for symbol, stop_price in plan.stop_prices.items(): parts.append( - f"{stop_price.symbol}\tstop gain: {stop_price.stop_gain_price}\tstop loss: {stop_price.stop_loss_price}" + f"{symbol}\tstop gain: {stop_price.stop_gain_price}\tstop loss: {stop_price.stop_loss_price}" ) parts.append("") diff --git a/python/valuecell/agents/common/trading/models.py b/python/valuecell/agents/common/trading/models.py index c2268a6f4..f10a5ea8f 100644 --- a/python/valuecell/agents/common/trading/models.py +++ b/python/valuecell/agents/common/trading/models.py @@ -551,8 +551,8 @@ class PortfolioView(BaseModel): ), ) stop_prices: Dict[str, "StopPrice"] = Field( - default_factory=list, - description="List of stop prices for existing positions and positions to open.", + default_factory=dict, + description="Dictionary of stop prices for existing positions and positions to open.", ) @@ -592,7 +592,6 @@ def derive_side_from_action( class StopPrice(BaseModel): - symbol: str = Field(..., description="Exchange symbol, e.g., BTC/USDT") stop_gain_price: Optional[float] = Field( ..., description="Stop gain price for this position.", @@ -657,9 +656,9 @@ class TradePlanProposal(BaseModel): rationale: Optional[str] = Field( default=None, description="Optional natural language rationale" ) - stop_prices: List[StopPrice] = Field( - default_factory=list, - description="List of stop prices for existing positions and positions to open.", + stop_prices: Dict[str, StopPrice] = Field( + default_factory=dict, + description="Map of ticker symbols to their respective stop prices", ) @@ -931,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) @@ -954,7 +957,7 @@ class ComposeResult(BaseModel): instructions: List[TradeInstruction] rationale: Optional[str] = None - stop_prices: List[StopPrice] = [] + stop_prices: Dict[str, StopPrice] = {} class FeaturesPipelineResult(BaseModel): @@ -977,4 +980,3 @@ class DecisionCycleResult: history_records: List[HistoryRecord] digest: TradeDigest portfolio_view: PortfolioView - stop_prices: List[StopPrice] diff --git a/python/valuecell/agents/common/trading/portfolio/in_memory.py b/python/valuecell/agents/common/trading/portfolio/in_memory.py index 7b8bfa9f7..4d4cac4c2 100644 --- a/python/valuecell/agents/common/trading/portfolio/in_memory.py +++ b/python/valuecell/agents/common/trading/portfolio/in_memory.py @@ -14,7 +14,6 @@ TradingMode, ) from valuecell.agents.common.trading.utils import extract_price_map -from valuecell.server.db.models import StrategyStopPrices from .interfaces import BasePortfolioService @@ -43,7 +42,7 @@ def __init__( initial_positions: Dict[str, PositionSnapshot], trading_mode: TradingMode, market_type: MarketType, - stop_prices: Dict[str, StrategyStopPrices], + stop_prices: Dict[str, StopPrice], constraints: Optional[Constraints] = None, strategy_id: Optional[str] = None, ) -> None: @@ -93,21 +92,15 @@ def get_view(self) -> PortfolioView: pass return self._view - def update_stop_prices(self, stop_prices: List[StopPrice]) -> None: - for stop_price in stop_prices: - if stop_price.symbol in self._view.stop_prices: - self._view.stop_prices[stop_price.symbol].stop_gain_price = ( - stop_price.stop_gain_price - if stop_price.stop_gain_price is not None - else self._view.stop_prices[stop_price.symbol].stop_gain_price - ) - self._view.stop_prices[stop_price.symbol].stop_loss_price = ( - stop_price.stop_loss_price - if stop_price.stop_loss_price is not None - else self._view.stop_prices[stop_price.symbol].stop_loss_price - ) + 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[stop_price.symbol] = stop_price + self._view.stop_prices[symbol] = new_stop def apply_trades( self, trades: List[TradeHistoryEntry], market_features: List[FeatureVector] diff --git a/python/valuecell/agents/common/trading/portfolio/interfaces.py b/python/valuecell/agents/common/trading/portfolio/interfaces.py index 669a5d0bb..544944a87 100644 --- a/python/valuecell/agents/common/trading/portfolio/interfaces.py +++ b/python/valuecell/agents/common/trading/portfolio/interfaces.py @@ -1,7 +1,7 @@ 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, @@ -35,7 +35,7 @@ def apply_trades( """ raise NotImplementedError - def update_stop_prices(self, stop_prices: List[StopPrice]) -> None: + 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) diff --git a/python/valuecell/server/db/models/__init__.py b/python/valuecell/server/db/models/__init__.py index c9885b864..e8d8688e9 100644 --- a/python/valuecell/server/db/models/__init__.py +++ b/python/valuecell/server/db/models/__init__.py @@ -17,7 +17,6 @@ from .strategy_holding import StrategyHolding from .strategy_instruction import StrategyInstruction from .strategy_portfolio import StrategyPortfolioView -from .strategy_stop_price import StrategyStopPrices from .user_profile import ProfileCategory, UserProfile from .watchlist import Watchlist, WatchlistItem @@ -36,5 +35,4 @@ "StrategyPortfolioView", "StrategyComposeCycle", "StrategyInstruction", - "StrategyStopPrices", ] diff --git a/python/valuecell/server/db/models/strategy_stop_price.py b/python/valuecell/server/db/models/strategy_stop_price.py deleted file mode 100644 index 022cafafb..000000000 --- a/python/valuecell/server/db/models/strategy_stop_price.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -ValueCell Server - Strategy Stop Prices Model - -This module defines the database model for strategy stop price records. -Each row represents a stop gain & loss info associated with a strategy and symbol. -""" - -from typing import Any, Dict - -from sqlalchemy import ( - Column, - DateTime, - ForeignKey, - Integer, - Numeric, - String, - Text, - UniqueConstraint, -) -from sqlalchemy.sql import func - -from .base import Base - - -class StrategyStopPrices(Base): - """Strategy detail record for trades/positions associated with a strategy.""" - - __tablename__ = "strategy_stop_prices" - - # Primary key - id = Column(Integer, primary_key=True, index=True) - - # Foreign key to strategies (uses unique strategy_id) - strategy_id = Column( - String(100), - ForeignKey("strategies.strategy_id", ondelete="CASCADE"), - nullable=False, - index=True, - comment="Runtime strategy identifier", - ) - - # Instrument and trade info - symbol = Column(String(50), nullable=False, index=True, comment="Instrument symbol") - stop_gain_price = Column( - Numeric(20, 8), nullable=True, comment="Price to stop gain" - ) - stop_loss_price = Column( - Numeric(20, 8), nullable=True, comment="Price to stop loss" - ) - - # Notes - note = Column(Text, nullable=True, comment="Optional note") - - # Timestamps - created_at = Column( - DateTime(timezone=True), server_default=func.now(), nullable=False - ) - updated_at = Column( - DateTime(timezone=True), - server_default=func.now(), - onupdate=func.now(), - nullable=False, - ) - - # Uniqueness: strategy_id + trade_id must be unique - __table_args__ = ( - UniqueConstraint("strategy_id", "symbol", name="uq_strategy_id_symbol"), - ) - - def __repr__(self) -> str: - return ( - f"" - ) - - def to_dict(self) -> Dict[str, Any]: - return { - "id": self.id, - "strategy_id": self.strategy_id, - "symbol": self.symbol, - "stop_gain_price": ( - float(self.stop_gain_price) - if self.stop_gain_price is not None - else None - ), - "stop_loss_price": ( - float(self.stop_loss_price) - if self.stop_loss_price is not None - else None - ), - "note": self.note, - "created_at": self.created_at.isoformat() if self.created_at else None, - "updated_at": self.updated_at.isoformat() if self.updated_at else None, - } diff --git a/python/valuecell/server/db/repositories/strategy_repository.py b/python/valuecell/server/db/repositories/strategy_repository.py index 9b54865a4..861913ade 100644 --- a/python/valuecell/server/db/repositories/strategy_repository.py +++ b/python/valuecell/server/db/repositories/strategy_repository.py @@ -11,7 +11,6 @@ from sqlalchemy import asc, desc, func from sqlalchemy.orm import Session -from ....agents.common.trading.models import StopPrice from ..connection import get_database_manager from ..models.strategy import Strategy from ..models.strategy_compose_cycle import StrategyComposeCycle @@ -20,7 +19,6 @@ from ..models.strategy_instruction import StrategyInstruction from ..models.strategy_portfolio import StrategyPortfolioView from ..models.strategy_prompt import StrategyPrompt -from ..models.strategy_stop_price import StrategyStopPrices class StrategyRepository: @@ -447,59 +445,6 @@ def add_instruction( if not self.db_session: session.close() - # Stop price operations - def upsert_stop_price( - self, - strategy_id: str, - stop_prices: List[StopPrice], - note: Optional[str] = None, - ) -> List[StrategyStopPrices]: - """Insert one strategy detail record.""" - session = self._get_session() - upserted_items = [] - try: - for stop_price in stop_prices: - existing_item = ( - session.query(StrategyStopPrices) - .filter( - StrategyStopPrices.strategy_id == strategy_id, - StrategyStopPrices.symbol == stop_price.symbol, - ) - .one_or_none() - ) - - if existing_item: - existing_item.stop_gain_price = stop_price.stop_gain_price - existing_item.stop_loss_price = stop_price.stop_loss_price - existing_item.note = note - item_to_return = existing_item - else: - new_item = StrategyStopPrices( - strategy_id=strategy_id, - symbol=stop_price.symbol, - stop_gain_price=stop_price.stop_gain_price, - stop_loss_price=stop_price.stop_loss_price, - note=note, - ) - session.add(new_item) - item_to_return = new_item - - session.add(item_to_return) - upserted_items.append(item_to_return) - - session.commit() - for item in upserted_items: - session.refresh(item) - session.expunge(item) - except Exception: - session.rollback() - return [] - finally: - if not self.db_session: - session.close() - - return upserted_items - def get_cycles( self, strategy_id: str, limit: Optional[int] = None ) -> List[StrategyComposeCycle]: @@ -585,21 +530,6 @@ def get_details( if not self.db_session: session.close() - def get_stop_prices(self, strategy_id: str) -> List[StrategyStopPrices]: - """Get detail records for a strategy ordered by event_time desc.""" - session = self._get_session() - try: - query = session.query(StrategyStopPrices).filter( - StrategyDetail.strategy_id == strategy_id - ) - items = query.all() - for item in items: - session.expunge(item) - return items - finally: - if not self.db_session: - session.close() - # Prompts operations (kept under strategy namespace) def list_prompts(self) -> List[StrategyPrompt]: """Return all prompts ordered by updated_at desc.""" diff --git a/python/valuecell/server/services/strategy_persistence.py b/python/valuecell/server/services/strategy_persistence.py index d8fd096d2..0526bca90 100644 --- a/python/valuecell/server/services/strategy_persistence.py +++ b/python/valuecell/server/services/strategy_persistence.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from typing import List, Optional +from typing import Optional from loguru import logger @@ -225,22 +225,6 @@ def persist_portfolio_view(view: agent_models.PortfolioView) -> bool: return False -def persist_stop_prices( - strategy_id: str, stop_prices: List[agent_models.StopPrice] -) -> bool: - """Persist a StrategySummary into the Strategy.strategy_metadata JSON. - - Returns True on success, False on failure. - """ - repo = get_strategy_repository() - try: - updated = repo.upsert_stop_price(strategy_id, stop_prices=stop_prices) - return updated is not None - except Exception as e: - logger.exception("persist_stop_prices failed for {}, error: {}", strategy_id, e) - return False - - def persist_strategy_summary(summary: agent_models.StrategySummary) -> bool: """Persist a StrategySummary into the Strategy.strategy_metadata JSON. From 9be822f9d188564f2de80f64186a0c1838ce58c9 Mon Sep 17 00:00:00 2001 From: Kelu Date: Thu, 26 Feb 2026 21:15:09 +0700 Subject: [PATCH 3/3] deploy: add GCP e2-micro VM deployment to Singapore (asia-southeast1) - Add multi-stage Dockerfile.cloud (bun SPA + Python backend) - Serve React SPA from FastAPI via FRONTEND_BUILD_DIR env var - Add docker-compose.yml and vm-setup.sh for GCE VM - Add GitHub Actions workflow deploying via SSH to 34.142.253.116 - Remove Cloud Run / OCI configs Co-Authored-By: Claude Sonnet 4.5 --- .github/workflows/deploy.yml | 112 +++++++++++++++++++++++++ cloud/docker-compose.yml | 45 ++++++++++ cloud/vm-setup.sh | 129 +++++++++++++++++++++++++++++ docker/Dockerfile.cloud | 52 ++++++++++++ python/valuecell/server/api/app.py | 54 +++++++++--- 5 files changed, 381 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 cloud/docker-compose.yml create mode 100755 cloud/vm-setup.sh create mode 100644 docker/Dockerfile.cloud 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/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()