diff --git a/Dockerfile.railway b/Dockerfile.railway index e6127e203f..dd246257ea 100644 --- a/Dockerfile.railway +++ b/Dockerfile.railway @@ -10,7 +10,7 @@ FROM ghcr.io/nofxaios/nofx/nofx-frontend:latest AS frontend # 最终镜像 FROM alpine:latest -RUN apk add --no-cache ca-certificates tzdata sqlite nginx openssl gettext +RUN apk add --no-cache ca-certificates tzdata sqlite nginx openssl gettext wget # 复制后端二进制 COPY --from=backend /app/nofx /app/nofx @@ -23,6 +23,8 @@ RUN ldconfig /usr/local/lib 2>/dev/null || true COPY --from=frontend /usr/share/nginx/html /usr/share/nginx/html WORKDIR /app +# /app/data holds data.db and logs. On Railway, mount a Volume at /app/data to persist +# registration and config across deploys. See docs/railway.md. RUN mkdir -p /app/data # 启动脚本(包含 nginx 配置生成) diff --git a/Dockerfile.railway.fromsource b/Dockerfile.railway.fromsource new file mode 100644 index 0000000000..bd61f8182e --- /dev/null +++ b/Dockerfile.railway.fromsource @@ -0,0 +1,54 @@ +# Railway All-in-One: build backend FROM SOURCE (includes 0-candidate fallbacks) +# Use this when ghcr.io/nofxaios/nofx/nofx-backend does not include fallbacks. +# Set railway.toml: [build] dockerfilePath = "Dockerfile.railway.fromsource" + +ARG GO_VERSION=1.25-alpine +ARG ALPINE_VERSION=latest +ARG TA_LIB_VERSION=0.4.0 + +# ─── TA-Lib ─── +FROM alpine:${ALPINE_VERSION} AS ta-lib-builder +ARG TA_LIB_VERSION +RUN apk update && apk add --no-cache wget tar make gcc g++ musl-dev autoconf automake +RUN wget http://prdownloads.sourceforge.net/ta-lib/ta-lib-${TA_LIB_VERSION}-src.tar.gz && \ + tar -xzf ta-lib-${TA_LIB_VERSION}-src.tar.gz && cd ta-lib && \ + if [ "$(uname -m)" = "aarch64" ]; then \ + CONFIG_GUESS=$(find /usr/share -name config.guess 2>/dev/null | head -1); \ + CONFIG_SUB=$(find /usr/share -name config.sub 2>/dev/null | head -1); \ + [ -n "$CONFIG_GUESS" ] && cp "$CONFIG_GUESS" config.guess; \ + [ -n "$CONFIG_SUB" ] && cp "$CONFIG_SUB" config.sub; \ + chmod +x config.guess config.sub 2>/dev/null || true; \ + fi && \ + ./configure --prefix=/usr/local && make && make install && \ + cd .. && rm -rf ta-lib ta-lib-${TA_LIB_VERSION}-src.tar.gz + +# ─── Backend from source ─── +FROM golang:${GO_VERSION} AS backend-builder +RUN apk update && apk add --no-cache git make gcc g++ musl-dev +COPY --from=ta-lib-builder /usr/local /usr/local +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=1 GOOS=linux CGO_CFLAGS="-D_LARGEFILE64_SOURCE" \ + go build -trimpath -ldflags="-s -w" -o nofx . + +# ─── Frontend (pre-built) ─── +FROM ghcr.io/nofxaios/nofx/nofx-frontend:latest AS frontend + +# ─── Final ─── +FROM alpine:${ALPINE_VERSION} +RUN apk add --no-cache ca-certificates tzdata sqlite nginx openssl gettext wget +COPY --from=backend-builder /app/nofx /app/nofx +COPY --from=ta-lib-builder /usr/local/lib/libta_lib* /usr/local/lib/ +RUN ldconfig /usr/local/lib 2>/dev/null || true +COPY --from=frontend /usr/share/nginx/html /usr/share/nginx/html +WORKDIR /app +RUN mkdir -p /app/data +COPY railway/start.sh /app/start.sh +RUN chmod +x /app/start.sh +ENV DB_PATH=/app/data/data.db +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT:-8080}/health || exit 1 +CMD ["/app/start.sh"] diff --git a/README.md b/README.md index 95b2a44919..1eecc1ea33 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,11 @@ Deploy to Railway with one click - no server setup required: After deployment, Railway will provide a public URL to access your NOFX instance. +> **Persisting data (registration, strategies, etc.) on Railway** +> By default, data is lost on each new deploy because the container filesystem is ephemeral. To keep your data: +> - **Option A – Volume:** In your Railway service, add a **Volume**, set the **mount path** to `/app/data`, and redeploy. See [docs/railway.md](docs/railway.md). +> - **Option B – PostgreSQL:** Add a Postgres database in Railway and set `DB_TYPE=postgres` plus `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_SSLMODE=require` on your NOFX service. See [docs/railway.md](docs/railway.md). + ### Docker Compose (Manual) ```bash diff --git a/api/strategy.go b/api/strategy.go index 5f724ab6b4..5acd350da5 100644 --- a/api/strategy.go +++ b/api/strategy.go @@ -426,7 +426,8 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) { } var req struct { - Config store.StrategyConfig `json:"config" binding:"required"` + Config *store.StrategyConfig `json:"config"` + StrategyID string `json:"strategy_id"` PromptVariant string `json:"prompt_variant"` AIModelID string `json:"ai_model_id"` RunRealAI bool `json:"run_real_ai"` @@ -437,12 +438,33 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) { return } + // Prefer loading from DB when strategy_id is provided (avoids client serialization issues with coin_source.static_coins) + if req.StrategyID != "" { + strategy, err := s.store.Strategy().Get(userID, req.StrategyID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Strategy not found"}) + return + } + loaded, err := strategy.ParseConfig() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Strategy config invalid: " + err.Error()}) + return + } + req.Config = loaded + } + if req.Config == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "config or strategy_id is required"}) + return + } + if req.PromptVariant == "" { req.PromptVariant = "balanced" } + logger.Infof("[Strategy Test-Run] coin_source: source_type=%q static_coins=%v", req.Config.CoinSource.SourceType, req.Config.CoinSource.StaticCoins) + // Create strategy engine to build prompt - engine := kernel.NewStrategyEngine(&req.Config) + engine := kernel.NewStrategyEngine(req.Config) // Get candidate coins candidates, err := engine.GetCandidateCoins() @@ -454,6 +476,10 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) { }) return } + if len(candidates) == 0 { + logger.Warnf("[Strategy Test-Run] GetCandidateCoins returned 0; using [BTC] as fallback. Check strategy coin_source (source_type, static_coins).") + candidates = []kernel.CandidateCoin{{Symbol: "BTCUSDT", Sources: []string{"static"}}} + } // Get timeframe configuration timeframes := req.Config.Indicators.Klines.SelectedTimeframes @@ -537,6 +563,12 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) { // Build System Prompt systemPrompt := engine.BuildSystemPrompt(1000.0, req.PromptVariant) + // Defensive: ensure testContext.CandidateCoins is never empty before BuildUserPrompt + if len(testContext.CandidateCoins) == 0 { + logger.Warnf("[Strategy Test-Run] testContext.CandidateCoins is empty before BuildUserPrompt; using [BTCUSDT]. Check strategy coin_source.") + testContext.CandidateCoins = []kernel.CandidateCoin{{Symbol: "BTCUSDT", Sources: []string{"fallback"}}} + } + // Build User Prompt (using real market data) userPrompt := engine.BuildUserPrompt(testContext) @@ -632,11 +664,21 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string) } // Call AI API + logger.Infof("[Strategy Test-Run] Params to AI server: provider=%s model=%s system_prompt=%d chars user_prompt=%d chars", + provider, model.Name, len(systemPrompt), len(userPrompt)) + logger.Infof("[Strategy Test-Run] system_prompt: %s", systemPrompt) + logger.Infof("[Strategy Test-Run] user_prompt: %s", userPrompt) + response, err := aiClient.CallWithMessages(systemPrompt, userPrompt) + if err != nil { + logger.Errorf("[Strategy Test-Run] AI server error: %v", err) return "", fmt.Errorf("AI API call failed: %w", err) } + logger.Infof("[Strategy Test-Run] Response from AI server: %d chars", len(response)) + logger.Infof("[Strategy Test-Run] response: %s", response) + return response, nil } diff --git a/auth/auth.go b/auth/auth.go index a6bbe736e4..d2b2ce463c 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -117,7 +117,7 @@ func GenerateJWT(userID, email string) (string, error) { UserID: userID, Email: email, RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), // Expires in 24 hours + ExpiresAt: jwt.NewNumericDate(time.Now().Add(14 * 24 * time.Hour)), // Expires in 10 days IssuedAt: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(time.Now()), Issuer: "nofxAI", diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index 1279977299..283d439f1a 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -137,6 +137,11 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas 部署后,Railway 会提供一个公网 URL 访问你的 NOFX 实例。 +> **Railway 数据持久化(用户、策略等)** +> 默认每次重新部署后数据会丢失(容器文件系统是临时的)。若要保留数据: +> - **方式一:Volume**:在 Railway 服务中添加 **Volume**,将 **挂载路径** 设为 `/app/data`,然后重新部署。详见 [docs/railway.md](../../railway.md)。 +> - **方式二:PostgreSQL**:在 Railway 添加 Postgres 数据库,并在 NOFX 服务中设置 `DB_TYPE=postgres` 以及 `DB_HOST`、`DB_USER`、`DB_PASSWORD`、`DB_NAME`、`DB_SSLMODE=require`。详见 [docs/railway.md](../../railway.md)。 + ### Docker Compose (手动) ```bash diff --git a/docs/railway.md b/docs/railway.md new file mode 100644 index 0000000000..a0fed0a28c --- /dev/null +++ b/docs/railway.md @@ -0,0 +1,159 @@ +# Deploying NOFX on Railway + +This guide covers deploying NOFX to [Railway](https://railway.app) and **persisting your data** (user accounts, strategies, traders, etc.) across deploys. + +--- + +## Local vs Railway: How They Differ + +| Aspect | **Local** | **Railway** | +|--------|-----------|-------------| +| **Docker** | Optional. You can: (1) run `./nofx` directly (no Docker), or (2) use `./start.sh` → `docker compose` (Docker). | **Always Docker.** Railway builds and runs the image from `Dockerfile.railway`. | +| **Layout** | With `./start.sh`: **2 containers** (backend `nofx` + frontend `nofx-frontend`). With `./nofx`: **1 process**, no frontend container (you serve the built `web/` yourself or use a separate server). | **1 container (all‑in‑one).** `Dockerfile.railway` puts the Go binary + nginx + built frontend in one image. `railway/start.sh` runs nofx on 8081 and nginx on `PORT`. | +| **Port** | Backend: `API_SERVER_PORT` (default 8080) or `NOFX_BACKEND_PORT` in compose. Frontend: `NOFX_FRONTEND_PORT` (default 3000). | Railway sets **`PORT`** (e.g. 8080). Nginx listens on `PORT`; the Go app runs on **8081** via `API_SERVER_PORT=8081` in `railway/start.sh`. | +| **Database** | SQLite: `DB_PATH` (default `data/data.db`). With Docker Compose, `./data` is mounted at `/app/data` so `data/data.db` or `/app/data/data.db` persists on the host. | SQLite: `DB_PATH=/app/data/data.db` in the image. **Without a Volume, the filesystem is ephemeral** — data is lost on redeploy. With a Volume at `/app/data`, it persists. | +| **Env file** | `.env` from the project root (and `env_file` in docker-compose). `godotenv.Load()` in `main.go` also loads `.env` when running `./nofx`. | No `.env` in the image. Set variables in the **Railway service → Variables**. Railway injects them into the container at runtime. | +| **Encryption keys** | `.env`: `JWT_SECRET`, `DATA_ENCRYPTION_KEY`, `RSA_PRIVATE_KEY`. `./start.sh` can generate them if missing. | Same variable names. If **not set** in Railway, `railway/start.sh` **auto‑generates** them at container start. That means keys change on each new container unless you set them explicitly in Railway Variables. | +| **Persistence** | With Docker: `./data` → `/app/data` persists on the host. With `./nofx`: `data/data.db` in the project directory persists. | **By default: none.** Add a **Volume** at `/app/data` or use **PostgreSQL** so data survives redeploys. | + +### Variables That Often Differ + +| Variable | Local (typical) | Railway (typical) | +|----------|----------------|-------------------| +| `PORT` | Not used by the Go app. | **Set by Railway.** Used by nginx in `railway/start.sh` to listen. | +| `API_SERVER_PORT` | 8080 (or from `.env`). | **Set to 8081** in `railway/start.sh` (nginx proxies `/api/` to 8081). | +| `DB_PATH` | `data/data.db` (relative to workdir) or `/app/data/data.db` in Docker. | **`/app/data/data.db`** (set in `Dockerfile.railway`). Must be under `/app/data` if you use a Volume. | +| `DB_TYPE` | `sqlite` (default). | `sqlite` with a Volume, or `postgres` if you use Railway Postgres. | +| `JWT_SECRET`, `DATA_ENCRYPTION_KEY`, `RSA_PRIVATE_KEY` | In `.env`; often generated once by `./start.sh`. | **Set in Railway Variables** so they stay fixed across deploys. If unset, `railway/start.sh` generates new ones each start → sessions/decryption can break. | +| `TRANSPORT_ENCRYPTION` | Often `false` for local HTTP. | `false` or `true`; does **not** affect DB or “Candidate Coins (0)” (see main README). | +| `NOFX_BACKEND_PORT`, `NOFX_FRONTEND_PORT` | Used by **docker-compose** and `./start.sh` for host port mapping. | **Not used** on Railway; `PORT` is the single public port. | + +### Is Railway Using Docker? + +Yes. `railway.toml` points to `Dockerfile.railway`. Railway builds that image and runs it in a container. The main difference from “local Docker” is: + +- **Local `docker-compose`**: two services (backend + frontend), `.env` and host volume `./data` for persistence. +- **Railway**: one image with backend + nginx + frontend, no `.env` (use Railway Variables), and **you must add a Volume or Postgres** for persistence. + +--- + +## Why does my data disappear on each deploy? + +Railway runs your app in a **container**. Each **new deploy** creates a **new container** with a fresh filesystem. Anything written to the container’s local disk (e.g. `data/data.db`) is **lost** when that container is replaced. + +To keep your data, you must store it in **persistent storage**. Railway offers two main options: + +1. **Volumes** – persistent disk attached to your service (keeps SQLite `data.db` and logs). +2. **PostgreSQL** – managed database; set `DB_TYPE=postgres` and connection env vars. + +--- + +## Option A: Railway Volume (recommended for SQLite) + +A **Volume** is a persistent disk. When you mount it at `/app/data`, the database and logs written there **survive redeploys**. + +### Steps + +1. **Open your Railway project** → select your NOFX **service**. +2. **Add a Volume** + - **Command Palette**: `⌘K` (Mac) or `Ctrl+K` (Windows) → “Add Volume”, or + - **Right‑click** on the project canvas → “Add Volume”. +3. **Attach the volume to your NOFX service** when prompted. +4. **Set the mount path** + - In the volume or service settings, set **Mount Path** to: + ```text + /app/data + ``` + - This must be exactly `/app/data` because: + - `DB_PATH` is `/app/data/data.db` in the Railway image. + - Logs go to `data/` (i.e. `/app/data/`) when the app runs with `WORKDIR /app`. + +5. **Redeploy** (or let the next deploy run). From that point on, `data.db` and logs under `/app/data` will persist across deploys. + +### Notes + +- The **first** time you add the volume, the filesystem at `/app/data` will be empty. The app will create `data.db` on first run. **You will need to register again** (or restore a backup) if you had data in the previous, non‑persistent container. +- After the volume is attached, **new** registrations and data will persist across future deploys. +- Do **not** set `DB_PATH` to something outside `/app/data` if you want it on the volume. The default `DB_PATH=/app/data/data.db` is correct when the volume is at `/app/data`. + +--- + +## Option B: PostgreSQL + +If you prefer a managed database, use **PostgreSQL** and switch NOFX to it. Data is stored in Postgres instead of a local file, so it persists regardless of the container. + +### Steps + +1. **Add PostgreSQL** in Railway + - “New” → “Database” → “PostgreSQL”, or add the Postgres plugin to your project. + +2. **Connect it to your NOFX service** + - In the Postgres service, use “Connect” / “Add to project” so your NOFX service can use it. Railway will set `DATABASE_URL` or `PGHOST`, `PGUSER`, etc. + - If you get `DATABASE_URL`, you can derive the individual vars from it, or set them explicitly. + +3. **Set environment variables** on your **NOFX service**: + + | Variable | Example / description | + |----------------|-------------------------------------------------| + | `DB_TYPE` | `postgres` | + | `DB_HOST` | from Railway (e.g. `containers-us-west-xxx.railway.app`) | + | `DB_PORT` | `5432` (or the port Railway shows) | + | `DB_USER` | from Railway | + | `DB_PASSWORD` | from Railway | + | `DB_NAME` | `railway` or the DB name Railway creates | + | `DB_SSLMODE` | `require` (Railway Postgres typically uses SSL) | + + Use the exact values from your Postgres service’s “Variables” or “Connect” tab. + +4. **Remove or do not set** `DB_PATH` when using Postgres (it is only for SQLite). + +5. **Deploy**. NOFX will create the tables in Postgres. Your users, strategies, and traders will persist across deploys. + +--- + +## Troubleshooting: “Candidate Coins (0)” even with /app/data + +A Volume at `/app/data` only **persists** the database. If the **strategy** stored in the DB has **no static coins** (or uses **ai500/oi_top**, which can fail on Railway), you will still see **0 coins** in the user prompt. + +### 1. Set Coin Source to **Static** and add coins + +1. Open **Strategy Studio**. +2. Select the **strategy** your trader uses. +3. Open the **Coin Source** (币种来源) section. +4. Set **Source Type** to **Static List**. +5. In **Custom Coins**, add at least **BTC** (e.g. type `BTC` or `BTCUSDT` and add). Optionally add ETH, SOL, DOGE. +6. Click **Save** (保存). + +### 2. Make sure the trader uses that strategy + +In **Config → Traders**, edit your trader and set **Strategy** to the same strategy you just saved. Save the trader. + +### 3. If the strategy is **ai500** or **oi_top** + +The **default** strategy uses **ai500**. That calls the NofxOS API; on Railway it can fail (no/ invalid NofxOS API key, or network). When it fails, candidate coins can be 0. + +- **Preferred:** Switch the strategy to **Static** and add BTC (and others) as above; then Save. +- **Alternatively:** In the strategy’s **Indicators** section, set a valid **NofxOS API Key** and ensure Railway can reach the NofxOS API. + +### 4. (Optional) Deploy a build that includes fallbacks + +The image built from `Dockerfile.railway` uses the pre-built `ghcr.io/nofxaios/nofx/nofx-backend` binary, which may **not** include the “[BTC] when 0” fallbacks. To include them, build the backend from source: + +1. In `railway.toml`, set: + ```toml + [build] + dockerfilePath = "Dockerfile.railway.fromsource" + ``` +2. Redeploy. The build will be slower, but the running app will use the fallbacks (e.g. [BTC] when the strategy returns 0). + +--- + +## Summary + +| Goal | Approach | +|-----------------------------|--------------------------------------------------| +| Keep SQLite and logs | Add a **Volume**, mount path **`/app/data`** | +| Use a managed DB | Add **PostgreSQL**, set **`DB_TYPE=postgres`** and DB_* vars | +| Fix “0 coins” in prompt | Strategy: **Static** + add **BTC** (and others) → **Save**; ensure the **trader** uses that strategy | + +After that, your registration and other data will **no longer be deleted** on each deploy. diff --git a/kernel/engine.go b/kernel/engine.go index d4010070f3..9405821170 100644 --- a/kernel/engine.go +++ b/kernel/engine.go @@ -106,25 +106,25 @@ type RecentOrder struct { // Context trading context (complete information passed to AI) type Context struct { - CurrentTime string `json:"current_time"` - RuntimeMinutes int `json:"runtime_minutes"` - CallCount int `json:"call_count"` - Account AccountInfo `json:"account"` - Positions []PositionInfo `json:"positions"` - CandidateCoins []CandidateCoin `json:"candidate_coins"` - PromptVariant string `json:"prompt_variant,omitempty"` - TradingStats *TradingStats `json:"trading_stats,omitempty"` - RecentOrders []RecentOrder `json:"recent_orders,omitempty"` - MarketDataMap map[string]*market.Data `json:"-"` - MultiTFMarket map[string]map[string]*market.Data `json:"-"` - OITopDataMap map[string]*OITopData `json:"-"` - QuantDataMap map[string]*QuantData `json:"-"` - OIRankingData *nofxos.OIRankingData `json:"-"` // Market-wide OI ranking data - NetFlowRankingData *nofxos.NetFlowRankingData `json:"-"` // Market-wide fund flow ranking data - PriceRankingData *nofxos.PriceRankingData `json:"-"` // Market-wide price gainers/losers - BTCETHLeverage int `json:"-"` - AltcoinLeverage int `json:"-"` - Timeframes []string `json:"-"` + CurrentTime string `json:"current_time"` + RuntimeMinutes int `json:"runtime_minutes"` + CallCount int `json:"call_count"` + Account AccountInfo `json:"account"` + Positions []PositionInfo `json:"positions"` + CandidateCoins []CandidateCoin `json:"candidate_coins"` + PromptVariant string `json:"prompt_variant,omitempty"` + TradingStats *TradingStats `json:"trading_stats,omitempty"` + RecentOrders []RecentOrder `json:"recent_orders,omitempty"` + MarketDataMap map[string]*market.Data `json:"-"` + MultiTFMarket map[string]map[string]*market.Data `json:"-"` + OITopDataMap map[string]*OITopData `json:"-"` + QuantDataMap map[string]*QuantData `json:"-"` + OIRankingData *nofxos.OIRankingData `json:"-"` // Market-wide OI ranking data + NetFlowRankingData *nofxos.NetFlowRankingData `json:"-"` // Market-wide fund flow ranking data + PriceRankingData *nofxos.PriceRankingData `json:"-"` // Market-wide price gainers/losers + BTCETHLeverage int `json:"-"` + AltcoinLeverage int `json:"-"` + Timeframes []string `json:"-"` } // Decision AI trading decision @@ -262,6 +262,13 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S engine = NewStrategyEngine(&defaultConfig) } + // Defensive: ensure CandidateCoins is never empty so "Candidate Coins (0 coins)" cannot appear. + // buildTradingContext and Test-Run already apply [BTC] when GetCandidateCoins returns 0; this catches any other path. + if len(ctx.CandidateCoins) == 0 { + logger.Warnf("⚠️ GetFullDecisionWithStrategy: ctx.CandidateCoins is empty; using [BTCUSDT] so the prompt and market fetch can run. Fix strategy coin_source (source_type, static_coins).") + ctx.CandidateCoins = []CandidateCoin{{Symbol: "BTCUSDT", Sources: []string{"fallback"}}} + } + // 1. Fetch market data using strategy config if len(ctx.MarketDataMap) == 0 { if err := fetchMarketDataWithStrategy(ctx, engine); err != nil { @@ -292,6 +299,10 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S // 3. Build User Prompt using strategy engine userPrompt := engine.BuildUserPrompt(ctx) + // Log prompts sent to AI (for debugging) + logger.Info("\n========== AI REQUEST: System Prompt ==========\n" + systemPrompt) + logger.Info("\n========== AI REQUEST: User Prompt ==========\n" + userPrompt) + // 4. Call AI API aiCallStart := time.Now() aiResponse, err := mcpClient.CallWithMessages(systemPrompt, userPrompt) @@ -300,6 +311,9 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S return nil, fmt.Errorf("AI API call failed: %w", err) } + // Log response received from AI (for debugging) + logger.Info("\n========== AI RESPONSE ==========\n" + aiResponse) + // 5. Parse AI response decision, err := parseFullDecisionResponse( aiResponse, @@ -369,12 +383,12 @@ func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error { } // 2. Fetch data for all candidate coins - positionSymbols := make(map[string]bool) - for _, pos := range ctx.Positions { - positionSymbols[pos.Symbol] = true - } - - const minOIThresholdMillions = 15.0 // 15M USD minimum open interest value + // OI threshold filter disabled below - all coins are included regardless of OI value. To re-enable, uncomment the block. + // positionSymbols := make(map[string]bool) + // for _, pos := range ctx.Positions { + // positionSymbols[pos.Symbol] = true + // } + // const minOIThresholdMillions = 15.0 // 15M USD minimum open interest value for _, coin := range ctx.CandidateCoins { if _, exists := ctx.MarketDataMap[coin.Symbol]; exists { @@ -388,22 +402,29 @@ func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error { } // Liquidity filter (skip for xyz dex assets - they don't have OI data from Binance) - isExistingPosition := positionSymbols[coin.Symbol] - isXyzAsset := market.IsXyzDexAsset(coin.Symbol) - if !isExistingPosition && !isXyzAsset && data.OpenInterest != nil && data.CurrentPrice > 0 { - oiValue := data.OpenInterest.Latest * data.CurrentPrice - oiValueInMillions := oiValue / 1_000_000 - if oiValueInMillions < minOIThresholdMillions { - logger.Infof("⚠️ %s OI value too low (%.2fM USD < %.1fM), skipping coin", - coin.Symbol, oiValueInMillions, minOIThresholdMillions) - continue - } - } + // isExistingPosition := positionSymbols[coin.Symbol] + // isXyzAsset := market.IsXyzDexAsset(coin.Symbol) + // if !isExistingPosition && !isXyzAsset && data.OpenInterest != nil && data.CurrentPrice > 0 { + // oiValue := data.OpenInterest.Latest * data.CurrentPrice + // oiValueInMillions := oiValue / 1_000_000 + // if oiValueInMillions < minOIThresholdMillions { + // logger.Infof("⚠️ %s OI value too low (%.2fM USD < %.1fM), skipping coin", + // coin.Symbol, oiValueInMillions, minOIThresholdMillions) + // continue + // } + // } ctx.MarketDataMap[coin.Symbol] = data } - logger.Infof("📊 Successfully fetched multi-timeframe market data for %d coins", len(ctx.MarketDataMap)) + candidatesWithData := 0 + for _, c := range ctx.CandidateCoins { + if _, ok := ctx.MarketDataMap[c.Symbol]; ok { + candidatesWithData++ + } + } + logger.Infof("📊 Successfully fetched multi-timeframe market data for %d coins (candidate coins: %d, with data: %d)", + len(ctx.MarketDataMap), len(ctx.CandidateCoins), candidatesWithData) return nil } @@ -417,10 +438,18 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) { symbolSources := make(map[string][]string) coinSource := e.config.CoinSource + logger.Infof("📋 GetCandidateCoins: source_type=%q static_coins_len=%d", coinSource.SourceType, len(coinSource.StaticCoins)) switch coinSource.SourceType { case "static": - for _, symbol := range coinSource.StaticCoins { + staticCoins := coinSource.StaticCoins + if len(staticCoins) == 0 { + logger.Warnf("⚠️ coin_source.source_type is 'static' but static_coins is empty or missing in strategy config. " + + "Check that the strategy was saved with static coins (e.g. BTC) in the UI. " + + "Falling back to [BTC] so at least one candidate is available.") + staticCoins = []string{"BTC"} + } + for _, symbol := range staticCoins { symbol = market.Normalize(symbol) candidates = append(candidates, CandidateCoin{ Symbol: symbol, @@ -1139,9 +1168,9 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string { // BTC market if btcData, hasBTC := ctx.MarketDataMap["BTCUSDT"]; hasBTC { - sb.WriteString(fmt.Sprintf("BTC: %.2f (1h: %+.2f%%, 4h: %+.2f%%) | MACD: %.4f | RSI: %.2f\n\n", + sb.WriteString(fmt.Sprintf("BTC: %.2f (1h: %+.2f%%, 4h: %+.2f%%) | MACD: %.4f | \n\n", btcData.CurrentPrice, btcData.PriceChange1h, btcData.PriceChange4h, - btcData.CurrentMACD, btcData.CurrentRSI7)) + btcData.CurrentMACD)) } // Account information @@ -1171,65 +1200,65 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string { } // Historical trading statistics (helps AI understand past performance) - if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 { - // Get language from strategy config - lang := e.GetLanguage() - - // Win/Loss ratio - var winLossRatio float64 - if ctx.TradingStats.AvgLoss > 0 { - winLossRatio = ctx.TradingStats.AvgWin / ctx.TradingStats.AvgLoss - } - - if lang == LangChinese { - sb.WriteString("## 历史交易统计\n") - sb.WriteString(fmt.Sprintf("总交易: %d 笔 | 盈利因子: %.2f | 夏普比率: %.2f | 盈亏比: %.2f\n", - ctx.TradingStats.TotalTrades, - ctx.TradingStats.ProfitFactor, - ctx.TradingStats.SharpeRatio, - winLossRatio)) - sb.WriteString(fmt.Sprintf("总盈亏: %+.2f USDT | 平均盈利: +%.2f | 平均亏损: -%.2f | 最大回撤: %.1f%%\n", - ctx.TradingStats.TotalPnL, - ctx.TradingStats.AvgWin, - ctx.TradingStats.AvgLoss, - ctx.TradingStats.MaxDrawdownPct)) - - // Performance hints based on profit factor, sharpe, and drawdown - if ctx.TradingStats.ProfitFactor >= 1.5 && ctx.TradingStats.SharpeRatio >= 1 { - sb.WriteString("表现: 良好 - 保持当前策略\n") - } else if ctx.TradingStats.ProfitFactor < 1 { - sb.WriteString("表现: 需改进 - 提高盈亏比,优化止盈止损\n") - } else if ctx.TradingStats.MaxDrawdownPct > 30 { - sb.WriteString("表现: 风险偏高 - 减少仓位,控制回撤\n") - } else { - sb.WriteString("表现: 正常 - 有优化空间\n") - } - } else { - sb.WriteString("## Historical Trading Statistics\n") - sb.WriteString(fmt.Sprintf("Total Trades: %d | Profit Factor: %.2f | Sharpe: %.2f | Win/Loss Ratio: %.2f\n", - ctx.TradingStats.TotalTrades, - ctx.TradingStats.ProfitFactor, - ctx.TradingStats.SharpeRatio, - winLossRatio)) - sb.WriteString(fmt.Sprintf("Total PnL: %+.2f USDT | Avg Win: +%.2f | Avg Loss: -%.2f | Max Drawdown: %.1f%%\n", - ctx.TradingStats.TotalPnL, - ctx.TradingStats.AvgWin, - ctx.TradingStats.AvgLoss, - ctx.TradingStats.MaxDrawdownPct)) - - // Performance hints based on profit factor, sharpe, and drawdown - if ctx.TradingStats.ProfitFactor >= 1.5 && ctx.TradingStats.SharpeRatio >= 1 { - sb.WriteString("Performance: GOOD - maintain current strategy\n") - } else if ctx.TradingStats.ProfitFactor < 1 { - sb.WriteString("Performance: NEEDS IMPROVEMENT - improve win/loss ratio, optimize TP/SL\n") - } else if ctx.TradingStats.MaxDrawdownPct > 30 { - sb.WriteString("Performance: HIGH RISK - reduce position size, control drawdown\n") - } else { - sb.WriteString("Performance: NORMAL - room for optimization\n") - } - } - sb.WriteString("\n") - } + // if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 { + // // Get language from strategy config + // lang := e.GetLanguage() + + // // Win/Loss ratio + // var winLossRatio float64 + // if ctx.TradingStats.AvgLoss > 0 { + // winLossRatio = ctx.TradingStats.AvgWin / ctx.TradingStats.AvgLoss + // } + + // if lang == LangChinese { + // sb.WriteString("## 历史交易统计\n") + // sb.WriteString(fmt.Sprintf("总交易: %d 笔 | 盈利因子: %.2f | 夏普比率: %.2f | 盈亏比: %.2f\n", + // ctx.TradingStats.TotalTrades, + // ctx.TradingStats.ProfitFactor, + // ctx.TradingStats.SharpeRatio, + // winLossRatio)) + // sb.WriteString(fmt.Sprintf("总盈亏: %+.2f USDT | 平均盈利: +%.2f | 平均亏损: -%.2f | 最大回撤: %.1f%%\n", + // ctx.TradingStats.TotalPnL, + // ctx.TradingStats.AvgWin, + // ctx.TradingStats.AvgLoss, + // ctx.TradingStats.MaxDrawdownPct)) + + // // Performance hints based on profit factor, sharpe, and drawdown + // if ctx.TradingStats.ProfitFactor >= 1.5 && ctx.TradingStats.SharpeRatio >= 1 { + // sb.WriteString("表现: 良好 - 保持当前策略\n") + // } else if ctx.TradingStats.ProfitFactor < 1 { + // sb.WriteString("表现: 需改进 - 提高盈亏比,优化止盈止损\n") + // } else if ctx.TradingStats.MaxDrawdownPct > 30 { + // sb.WriteString("表现: 风险偏高 - 减少仓位,控制回撤\n") + // } else { + // sb.WriteString("表现: 正常 - 有优化空间\n") + // } + // } else { + // sb.WriteString("## Historical Trading Statistics\n") + // sb.WriteString(fmt.Sprintf("Total Trades: %d | Profit Factor: %.2f | Sharpe: %.2f | Win/Loss Ratio: %.2f\n", + // ctx.TradingStats.TotalTrades, + // ctx.TradingStats.ProfitFactor, + // ctx.TradingStats.SharpeRatio, + // winLossRatio)) + // sb.WriteString(fmt.Sprintf("Total PnL: %+.2f USDT | Avg Win: +%.2f | Avg Loss: -%.2f | Max Drawdown: %.1f%%\n", + // ctx.TradingStats.TotalPnL, + // ctx.TradingStats.AvgWin, + // ctx.TradingStats.AvgLoss, + // ctx.TradingStats.MaxDrawdownPct)) + + // // Performance hints based on profit factor, sharpe, and drawdown + // if ctx.TradingStats.ProfitFactor >= 1.5 && ctx.TradingStats.SharpeRatio >= 1 { + // sb.WriteString("Performance: GOOD - maintain current strategy\n") + // } else if ctx.TradingStats.ProfitFactor < 1 { + // sb.WriteString("Performance: NEEDS IMPROVEMENT - improve win/loss ratio, optimize TP/SL\n") + // } else if ctx.TradingStats.MaxDrawdownPct > 30 { + // sb.WriteString("Performance: HIGH RISK - reduce position size, control drawdown\n") + // } else { + // sb.WriteString("Performance: NORMAL - room for optimization\n") + // } + // } + // sb.WriteString("\n") + // } // Position information if len(ctx.Positions) > 0 { @@ -1249,8 +1278,12 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string { positionSymbols[normalizedSymbol] = true } - sb.WriteString(fmt.Sprintf("## Candidate Coins (%d coins)\n\n", len(ctx.MarketDataMap))) + sb.WriteString(fmt.Sprintf("## Candidate Coins (%d coins)\n\n", len(ctx.CandidateCoins))) displayedCount := 0 + unavailMsg := "market data temporarily unavailable" + if e.GetLanguage() == LangChinese { + unavailMsg = "行情数据暂时不可用" + } for _, coin := range ctx.CandidateCoins { // Skip if this coin is already a position (data already shown in positions section) normalizedCoinSymbol := market.Normalize(coin.Symbol) @@ -1259,13 +1292,13 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string { } marketData, hasData := ctx.MarketDataMap[coin.Symbol] - if !hasData { - continue - } displayedCount++ - sourceTags := e.formatCoinSourceTag(coin.Sources) sb.WriteString(fmt.Sprintf("### %d. %s%s\n\n", displayedCount, coin.Symbol, sourceTags)) + if !hasData { + sb.WriteString("(" + unavailMsg + ")\n\n") + continue + } sb.WriteString(e.formatMarketData(marketData)) if ctx.QuantDataMap != nil { diff --git a/kernel/grid_engine.go b/kernel/grid_engine.go index f376e259a4..c320b6973d 100644 --- a/kernel/grid_engine.go +++ b/kernel/grid_engine.go @@ -449,12 +449,19 @@ func GetGridDecisions(ctx *GridContext, mcpClient mcp.AIClient, config *store.Gr logger.Infof("🤖 [Grid] Calling AI for grid decisions...") + // Log prompts sent to AI (for debugging) + logger.Info("\n========== [Grid] AI REQUEST: System Prompt ==========\n" + systemPrompt) + logger.Info("\n========== [Grid] AI REQUEST: User Prompt ==========\n" + userPrompt) + // Call AI response, err := mcpClient.CallWithMessages(systemPrompt, userPrompt) if err != nil { return nil, fmt.Errorf("AI call failed: %w", err) } + // Log response received from AI (for debugging) + logger.Info("\n========== [Grid] AI RESPONSE ==========\n" + response) + // Parse decisions from response decisions, err := parseGridDecisions(response, ctx.Symbol) if err != nil { diff --git a/kernel/schema.go b/kernel/schema.go index 9eaf899cec..845a30adbf 100644 --- a/kernel/schema.go +++ b/kernel/schema.go @@ -506,24 +506,24 @@ func getSchemaPromptEN() string { prompt += formatFieldDefEN(key, field) } - // Position Metrics - prompt += "\n### Position Metrics\n" - for key, field := range DataDictionary["PositionMetrics"] { - prompt += formatFieldDefEN(key, field) - } - - // Market Data - prompt += "\n### Market Data\n" - for key, field := range DataDictionary["MarketData"] { - prompt += formatFieldDefEN(key, field) - } - - // OI Interpretation - prompt += "\n## 💹 Open Interest (OI) Change Interpretation\n\n" - prompt += "- **OI Up + Price Up**: " + OIInterpretation.OIUp_PriceUp.EN + "\n" - prompt += "- **OI Up + Price Down**: " + OIInterpretation.OIUp_PriceDown.EN + "\n" - prompt += "- **OI Down + Price Up**: " + OIInterpretation.OIDown_PriceUp.EN + "\n" - prompt += "- **OI Down + Price Down**: " + OIInterpretation.OIDown_PriceDown.EN + "\n" + // // Position Metrics + // prompt += "\n### Position Metrics\n" + // for key, field := range DataDictionary["PositionMetrics"] { + // prompt += formatFieldDefEN(key, field) + // } + + // // Market Data + // prompt += "\n### Market Data\n" + // for key, field := range DataDictionary["MarketData"] { + // prompt += formatFieldDefEN(key, field) + // } + + // // OI Interpretation + // prompt += "\n## 💹 Open Interest (OI) Change Interpretation\n\n" + // prompt += "- **OI Up + Price Up**: " + OIInterpretation.OIUp_PriceUp.EN + "\n" + // prompt += "- **OI Up + Price Down**: " + OIInterpretation.OIUp_PriceDown.EN + "\n" + // prompt += "- **OI Down + Price Up**: " + OIInterpretation.OIDown_PriceUp.EN + "\n" + // prompt += "- **OI Down + Price Down**: " + OIInterpretation.OIDown_PriceDown.EN + "\n" return prompt } diff --git a/manager/trader_manager.go b/manager/trader_manager.go index 93dd10943d..e9186a9092 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -669,6 +669,7 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg IsCrossMargin: traderCfg.IsCrossMargin, ShowInCompetition: traderCfg.ShowInCompetition, StrategyConfig: strategyConfig, + StrategyID: traderCfg.StrategyID, } logger.Infof("📊 Loading trader %s: ScanIntervalMinutes=%d (from DB), ScanInterval=%v", diff --git a/railway.toml b/railway.toml index 153abfc5ed..1451c5b8f7 100644 --- a/railway.toml +++ b/railway.toml @@ -1,5 +1,7 @@ [build] -dockerfilePath = "Dockerfile.railway" +# Use fromsource so your commits (OI filter disabled, 0-candidate fallbacks) are in the binary. +# Dockerfile.railway copies ghcr.io/.../nofx-backend and ignores repo changes. +dockerfilePath = "Dockerfile.railway.fromsource" [deploy] healthcheckPath = "/health" diff --git a/railway/start.sh b/railway/start.sh index 89f5ee5639..ff9262c920 100644 --- a/railway/start.sh +++ b/railway/start.sh @@ -6,12 +6,16 @@ export PORT=${PORT:-8080} echo "🚀 Starting NOFX on port $PORT..." # 生成加密密钥(如果没有设置) +# RSA 用 \n 代替换行,避免 Railway 等平台对换行的处理导致 invalid PEM if [ -z "$RSA_PRIVATE_KEY" ]; then - export RSA_PRIVATE_KEY=$(openssl genrsa 2048 2>/dev/null) + export RSA_PRIVATE_KEY=$(openssl genrsa 2048 2>/dev/null | tr '\n' '#' | sed 's/#/\\n/g') fi if [ -z "$DATA_ENCRYPTION_KEY" ]; then export DATA_ENCRYPTION_KEY=$(openssl rand -base64 32) fi +if [ -z "$JWT_SECRET" ]; then + export JWT_SECRET=$(openssl rand -base64 32) +fi # 生成 nginx 配置 cat > /etc/nginx/http.d/default.conf << NGINX_EOF @@ -46,7 +50,23 @@ NGINX_EOF # 启动后端(端口 8081) API_SERVER_PORT=8081 /app/nofx & -sleep 2 +sleep 6 + +# 等待后端就绪(冷启动可能超过 4s),最多重试 8 次,每次间隔 2s +i=1 +while [ $i -le 8 ]; do + if wget -q -O- --timeout=3 http://127.0.0.1:8081/api/health >/dev/null 2>&1; then + break + fi + if [ $i -eq 8 ]; then + echo "❌ Backend still not responding on 8081 after ~22s." + echo "→ Scroll UP in this deploy's logs for nofx 'Fatal' or 'panic' — that is the real error." + echo "→ JWT_SECRET, DATA_ENCRYPTION_KEY, RSA_PRIVATE_KEY are auto-generated when unset. Leave them unset or fix the error shown above." + exit 1 + fi + sleep 2 + i=$((i + 1)) +done # 启动 nginx(后台) nginx diff --git a/trader/auto_trader.go b/trader/auto_trader.go index af31f5b2b8..432e500951 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -108,6 +108,8 @@ type AutoTraderConfig struct { // Strategy configuration (use complete strategy config) StrategyConfig *store.StrategyConfig // Strategy configuration (includes coin sources, indicators, risk control, prompts, etc.) + // StrategyID is used to reload strategy config from DB each cycle so UI changes (e.g. static_coins) are picked up without restart + StrategyID string } // AutoTrader automatic trader @@ -745,6 +747,30 @@ func (at *AutoTrader) runCycle() error { // buildTradingContext builds trading context func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) { + logName := at.name + if logName == "" { + logName = at.id + } + // 0. Reload strategy config from DB so UI changes (e.g. static_coins) are picked up without restart + if at.store == nil || at.config.StrategyID == "" { + cfg := at.strategyEngine.GetConfig() + logger.Infof("📋 [%s] trader_name=%s Strategy reload: skipped (store=%v, strategyID=%q) | in-memory: source_type=%q static_coins=%v", logName, logName, at.store != nil, at.config.StrategyID, cfg.CoinSource.SourceType, cfg.CoinSource.StaticCoins) + } else { + strategy, err := at.store.Strategy().Get(at.userID, at.config.StrategyID) + if err != nil { + logger.Warnf("⚠️ [%s] Failed to reload strategy config: %v (using in-memory config)", at.name, err) + } else { + newConfig, err := strategy.ParseConfig() + if err != nil { + logger.Warnf("⚠️ [%s] Failed to parse reloaded strategy config: %v (using in-memory config)", at.name, err) + } else { + at.strategyEngine = kernel.NewStrategyEngine(newConfig) + at.config.StrategyConfig = newConfig + logger.Infof("📋 [%s] Reloaded strategy from DB: source_type=%q static_coins=%v", at.name, newConfig.CoinSource.SourceType, newConfig.CoinSource.StaticCoins) + } + } + } + // 1. Get account information balance, err := at.trader.GetBalance() if err != nil { @@ -885,6 +911,11 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) { logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins)) } } + if len(candidateCoins) == 0 { + logger.Warnf("⚠️ [%s] GetCandidateCoins returned 0; using [BTC] as fallback so the cycle can run. Check strategy: coin_source.source_type and static_coins; if using ai500/oi_top, check NofxOS API key and network.", at.name) + candidateCoins = []kernel.CandidateCoin{{Symbol: "BTCUSDT", Sources: []string{"static"}}} + } + logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins)) // 4. Calculate total P&L totalPnL := totalEquity - at.initialBalance diff --git a/web/src/pages/StrategyStudioPage.tsx b/web/src/pages/StrategyStudioPage.tsx index 8b21d502bf..65ad6be4bd 100644 --- a/web/src/pages/StrategyStudioPage.tsx +++ b/web/src/pages/StrategyStudioPage.tsx @@ -457,18 +457,22 @@ export function StrategyStudioPage() { setIsRunningAiTest(true) setAiTestResult(null) try { + const body: Record = { + config: editingConfig, + prompt_variant: selectedVariant, + ai_model_id: selectedModelId, + run_real_ai: true, + } + // Prefer strategy_id so backend loads config from DB (avoids client serialization issues with coin_source.static_coins) + if (selectedStrategy?.id) body.strategy_id = selectedStrategy.id + const response = await fetch(`${API_BASE}/api/strategies/test-run`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ - config: editingConfig, - prompt_variant: selectedVariant, - ai_model_id: selectedModelId, - run_real_ai: true, - }), + body: JSON.stringify(body), }) if (!response.ok) throw new Error('Failed to run AI test') const data = await response.json()