mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-22 03:37:36 +08:00
Compare commits
104 Commits
feat/nofxi
...
39eac5aca7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39eac5aca7 | ||
|
|
0f3e71560c | ||
|
|
eabd279d10 | ||
|
|
09b7ac9e92 | ||
|
|
dc68884559 | ||
|
|
21407030ea | ||
|
|
7a66d048f3 | ||
|
|
8c8cd9b61f | ||
|
|
d84e22ab82 | ||
|
|
221cf3d61d | ||
|
|
390db72cdb | ||
|
|
c1b660bb45 | ||
|
|
c53a563bff | ||
|
|
e593874912 | ||
|
|
e9873b5b86 | ||
|
|
026c5d3089 | ||
|
|
ee5917adc6 | ||
|
|
9b70455e7f | ||
|
|
a744328313 | ||
|
|
b17d97effc | ||
|
|
e61f12d307 | ||
|
|
30f3feb586 | ||
|
|
49613830ce | ||
|
|
ea74f91de2 | ||
|
|
a6884998b3 | ||
|
|
277b5ad9c6 | ||
|
|
110bf52908 | ||
|
|
eba28bcf0e | ||
|
|
c4e79d9579 | ||
|
|
a4983d2cb0 | ||
|
|
24f6421a73 | ||
|
|
961e016d33 | ||
|
|
b95da3ed42 | ||
|
|
a47811f5ab | ||
|
|
2c6e2827e8 | ||
|
|
953240565f | ||
|
|
133ef51de8 | ||
|
|
bebe51bf89 | ||
|
|
3a048876bd | ||
|
|
4f3869c81c | ||
|
|
f3c33b55d7 | ||
|
|
785922697b | ||
|
|
332ddf61ef | ||
|
|
41c2625bb2 | ||
|
|
c0d8a9a375 | ||
|
|
9ea9bd705f | ||
|
|
094ab45476 | ||
|
|
220cb7428b | ||
|
|
1aea7abc38 | ||
|
|
577a0918c3 | ||
|
|
2d32a8f6c9 | ||
|
|
3c061aee94 | ||
|
|
30c6abca74 | ||
|
|
129952859e | ||
|
|
7f0a9f0749 | ||
|
|
b15c2da3a9 | ||
|
|
d008ccc6ab | ||
|
|
e4adafa364 | ||
|
|
1851508353 | ||
|
|
fcb73cc195 | ||
|
|
b9ae99da7e | ||
|
|
75832f9eb2 | ||
|
|
99361cb085 | ||
|
|
70db3f5ba3 | ||
|
|
f2eeea9659 | ||
|
|
eb73c8bdfa | ||
|
|
dea00b418c | ||
|
|
3b2e7027db | ||
|
|
f4ee723aa2 | ||
|
|
5bdffee3b0 | ||
|
|
c7c003cc3c | ||
|
|
f37fc9f887 | ||
|
|
908fc09aca | ||
|
|
ab5873e2de | ||
|
|
d80bb31c0a | ||
|
|
e2ccc6b911 | ||
|
|
bf289e8eb3 | ||
|
|
9f25bf49bf | ||
|
|
b8cde34e67 | ||
|
|
32e8a03a85 | ||
|
|
ca8bed4a58 | ||
|
|
94844b7139 | ||
|
|
e67a927a4f | ||
|
|
0f11be77f8 | ||
|
|
159f27dfdd | ||
|
|
25d0b30ea9 | ||
|
|
2d45e7ab15 | ||
|
|
fc6c42ac11 | ||
|
|
5ff7212cb3 | ||
|
|
3619f82796 | ||
|
|
03a307939e | ||
|
|
8d8a0cc72b | ||
|
|
fe0dbce367 | ||
|
|
b536265f93 | ||
|
|
30a703a827 | ||
|
|
d481b3d88c | ||
|
|
e8eafce1e0 | ||
|
|
ce3a8582af | ||
|
|
cfd91069d3 | ||
|
|
903eb591eb | ||
|
|
9ee931ee30 | ||
|
|
c244e4cdf1 | ||
|
|
737f9bca95 | ||
|
|
5c4e7502d7 |
12
.github/workflows/pr-checks.yml
vendored
12
.github/workflows/pr-checks.yml
vendored
@@ -273,7 +273,11 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@master
|
||||
# SECURITY: pinned to a full 40-char commit SHA (v0.36.0) — a mutable
|
||||
# version tag could be re-pointed by an upstream compromise (GHSA-69fq-xp46-6x23:
|
||||
# trivy-action's published artifacts were briefly poisoned). The trailing
|
||||
# comment records the human-readable version; Dependabot updates the SHA.
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: '.'
|
||||
@@ -299,7 +303,11 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run TruffleHog OSS
|
||||
uses: trufflesecurity/trufflehog@main
|
||||
# SECURITY: never use @main — upstream compromise = secret exfil.
|
||||
# TODO: pin to a full 40-char SHA from
|
||||
# https://github.com/trufflesecurity/trufflehog/releases and configure
|
||||
# Dependabot. Version tag is still mutable but is a major upgrade over @main.
|
||||
uses: trufflesecurity/trufflehog@v3.82.13
|
||||
with:
|
||||
path: ./
|
||||
base: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -44,6 +44,7 @@ decision_logs/
|
||||
nofx_test
|
||||
|
||||
# Node.js
|
||||
web/node_modules
|
||||
web/node_modules/
|
||||
node_modules/
|
||||
web/dist/
|
||||
@@ -52,6 +53,9 @@ web/.vite/
|
||||
# ESLint 临时报告文件(调试时生成,不纳入版本控制)
|
||||
eslint-*.json
|
||||
|
||||
# 本地 Agent QA seed(个人调试用,不纳入版本控制)
|
||||
docs/qa/fixtures/agent_self_play_seed.zh-CN.json
|
||||
|
||||
# VS code
|
||||
.vscode
|
||||
|
||||
@@ -129,3 +133,9 @@ PR_DESCRIPTION.md
|
||||
|
||||
# Go build artifacts
|
||||
/nofx-server
|
||||
.gstack/
|
||||
|
||||
# Local AI agent / skill scaffolding (not part of the runtime app)
|
||||
.agents/
|
||||
skills-lock.json
|
||||
img.png
|
||||
|
||||
1377
README.ja.md
1377
README.ja.md
File diff suppressed because it is too large
Load Diff
357
README.md
357
README.md
@@ -1,22 +1,14 @@
|
||||
<h1 align="center">NOFX</h1>
|
||||
<p align="center"><strong>Backed by <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Your personal AI trading assistant.</strong><br/>
|
||||
<strong>Any market. Any model. Pay with USDC, not API keys.</strong>
|
||||
<img src="docs/assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,122 +21,83 @@
|
||||
<a href="docs/i18n/vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
NOFX is an open-source **autonomous** AI trading assistant. Unlike traditional AI tools that require you to manually configure models, manage API keys, and wire up data sources — NOFX's AI **perceives markets, selects models, and fetches data entirely on its own**. Zero human intervention. You set the strategy, the AI handles everything else.
|
||||
NOFX is an open-source trading terminal where the strategy is a language model. Each trader runs a continuous loop — read market structure, decide, execute, record the reasoning — while a Go runtime clamps every order to hard risk limits the model cannot override.
|
||||
|
||||
**Fully autonomous**: The AI decides which model to use, what market data to pull, when to trade — all by itself. No manual model configuration. No juggling API keys for different services. Just fund a USDC wallet and let it run.
|
||||
|
||||
What makes it different: **built-in [x402](https://x402.org) micropayments**. No API keys. Fund a USDC wallet and pay per request. Your wallet is your identity.
|
||||
Traders compose freely: any model, any of nine exchanges, any strategy. Run several side by side and compare them on a public leaderboard by realized return. Everything runs on your own machine; exchange credentials are encrypted at rest and never leave it.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Open **http://127.0.0.1:3000**. Done.
|
||||
The terminal opens at `http://127.0.0.1:3000`.
|
||||
|
||||
---
|
||||
**First run**
|
||||
|
||||
## Quick Demo
|
||||
1. Register — the first account becomes the owner of the instance.
|
||||
2. Follow the guided launch: put **$1+ USDC** (Base network) in the AI fee wallet it creates for you, then connect Hyperliquid and deposit **$12+ USDC** to trade with.
|
||||
3. Start **Autopilot**. The AI scans the market every few minutes and trades on its own; every decision appears on the dashboard as it happens. Stop it anytime with one click.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
Click the cover image to watch the demo video.
|
||||
</p>
|
||||
## Register exchanges
|
||||
|
||||
---
|
||||
NOFX is free and open source. Opening an account through the partner links below carries reduced trading fees and funds continued development.
|
||||
|
||||
## How x402 Works
|
||||
|
||||
Traditional flow: register account → buy credits → get API key → manage quota → rotate keys.
|
||||
|
||||
x402 flow:
|
||||
|
||||
```
|
||||
Request → 402 (here's the price) → wallet signs USDC → retry → done
|
||||
```
|
||||
|
||||
No accounts. No API keys. No prepaid credits. One wallet, every model.
|
||||
|
||||
### Built-in x402 Providers
|
||||
|
||||
| Provider | Chain | Models |
|
||||
| :--------------------------------------------------------------------------------------------------------------------------------- | :---- | :-------------------------------------------------------------------- |
|
||||
| <img src="web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ models |
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
| Feature | Description |
|
||||
| :------------------ | :------------------------------------------------------------------------ |
|
||||
| **Multi-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — switch anytime |
|
||||
| **Multi-Exchange** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Strategy Studio** | Visual builder — coin sources, indicators, risk controls |
|
||||
| **AI Competition** | AIs compete in real-time, leaderboard ranks performance |
|
||||
| **Telegram Agent** | Chat with your trading assistant — streaming, tool calling, memory |
|
||||
| **Dashboard** | Live positions, P/L, AI decision logs with Chain of Thought |
|
||||
|
||||
### Markets
|
||||
|
||||
Crypto · US Stocks · Forex · Metals
|
||||
|
||||
### Exchanges (CEX)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
| :-------------------------------------------------------------------------------------------------------------------- | :----: | :----------------------------------------------------------------------------------- |
|
||||
| Exchange | Status | Register with fee discount |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Register](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
|
||||
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Register](https://www.okx.com/join/1865360) |
|
||||
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Register](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Exchanges (Perp-DEX)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :------------------------------------------------------ |
|
||||
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Register](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI Models (API Key Mode)
|
||||
<br/>
|
||||
|
||||
| AI Model | Status | Get API Key |
|
||||
| :--------------------------------------------------------------------------------------------------------------- | :----: | :-------------------------------------------------- |
|
||||
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Get API Key](https://platform.deepseek.com) |
|
||||
| <img src="web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Get API Key](https://dashscope.console.aliyun.com) |
|
||||
| <img src="web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Get API Key](https://platform.openai.com) |
|
||||
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Get API Key](https://console.anthropic.com) |
|
||||
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Get API Key](https://aistudio.google.com) |
|
||||
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Get API Key](https://console.x.ai) |
|
||||
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Get API Key](https://platform.moonshot.cn) |
|
||||
| <img src="web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Get API Key](https://platform.minimaxi.com) |
|
||||
## Demo
|
||||
|
||||
### AI Models (x402 Mode — No API Key)
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
15+ models via [Claw402](https://claw402.ai) — just a USDC wallet
|
||||
<br/>
|
||||
|
||||
---
|
||||
## The model proposes. The runtime disposes.
|
||||
|
||||
## Screenshots
|
||||
Decisions come from a language model reading the [Claw402.ai](https://claw402.ai) · Vergex data stack: a live signal board that ranks every market with directional bias and signal strength, per-symbol Signal Lab deep signals, cost-basis and liquidation heatmaps that show where the crowd's fuel and walls sit, and real-time market net flow — cross-checked against raw candles and the trader's own live track record. Execution does not.
|
||||
|
||||
Every order passes through limits enforced in code, outside the model's reach:
|
||||
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| Position limits | Max concurrent positions, notional capped as a ratio of equity, one position per symbol |
|
||||
| Leverage clamps | Hard caps applied at order-sizing time, independent of what the model requests |
|
||||
| Exchange-side protection | Stop-loss and take-profit placed on the exchange immediately after every entry |
|
||||
| Drawdown auto-close | Profitable positions that give back too much from their peak are closed |
|
||||
| Trade throttling | Minimum hold times, per-symbol re-entry cooldowns, per-cycle and per-hour entry limits |
|
||||
| Safe mode | Repeated model failures block new entries until the model recovers |
|
||||
| Launch preflight | Model access, wallet funds, strategy, and exchange balances verified before a trader may start |
|
||||
|
||||
Each decision is stored with the model's full reasoning. There is no position without a paper trail.
|
||||
|
||||
<br/>
|
||||
|
||||
## Terminal
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Autopilot** | Guided launch: fund, connect, deposit, start — with server-side preflight throughout |
|
||||
| **Strategy Studio** | Style presets, coin universes, indicators, leverage, entry confidence, custom prompts |
|
||||
| **Competition** | Public leaderboard ranked by realized return, each entry attributed to its model |
|
||||
| **Dashboard** | Live positions, orders, statistics, and the reasoning behind every decision |
|
||||
|
||||
<details>
|
||||
<summary><b>Config Page</b></summary>
|
||||
<summary>Screenshots</summary>
|
||||
|
||||
| AI Models & Exchanges | Traders List |
|
||||
| :----------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="screenshots/config-ai-exchanges.png" width="400"/> | <img src="screenshots/config-traders-list.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Dashboard</b></summary>
|
||||
<br/>
|
||||
|
||||
| Overview | Market Chart |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
@@ -154,162 +107,138 @@ Crypto · US Stocks · Forex · Metals
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="screenshots/dashboard-trading-stats.png" width="400"/> | <img src="screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| Positions | Trader Details |
|
||||
| :----------------------------------------------------------: | :---------------------------------------------------: |
|
||||
| <img src="screenshots/dashboard-positions.png" width="400"/> | <img src="screenshots/details-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Strategy Studio</b></summary>
|
||||
|
||||
| Strategy Editor | Indicators Config |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="screenshots/strategy-studio.png" width="400"/> | <img src="screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Competition</b></summary>
|
||||
|
||||
| Competition Mode |
|
||||
| :-------------------------------------------------------: |
|
||||
| <img src="screenshots/competition-page.png" width="400"/> |
|
||||
| Competition | Configuration |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="screenshots/competition-page.png" width="400"/> | <img src="screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Models
|
||||
|
||||
Eight providers with your own keys — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — including custom endpoints and model names.
|
||||
|
||||
Or no keys at all: [Claw402](https://claw402.ai) meters model usage per call in USDC over the x402 protocol. A wallet on Base replaces every API key.
|
||||
|
||||
| Provider | Access |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [Pay-as-you-go AI models with official discount](https://claw402.ai) |
|
||||
|
||||
## Markets
|
||||
|
||||
Crypto perpetuals on all nine exchanges. On Hyperliquid, the same runtime also trades tokenized US equities, commodities, indices, FX, and pre-IPO perps — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — alongside crypto.
|
||||
|
||||
<br/>
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
<br/>
|
||||
|
||||
## Install
|
||||
|
||||
### Linux / macOS
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (Cloud)
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Install [Docker Desktop](https://www.docker.com/products/docker-desktop/), then:
|
||||
**Windows** — install [Docker Desktop](https://www.docker.com/products/docker-desktop/), then:
|
||||
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### From Source
|
||||
**From source** — Go 1.21+, Node.js 18+:
|
||||
|
||||
```bash
|
||||
# Prerequisites: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend (new terminal)
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
### Update
|
||||
**Update** — re-run the install script; it upgrades in place.
|
||||
|
||||
<details>
|
||||
<summary>Server deployment</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
---
|
||||
**HTTPS via Cloudflare**
|
||||
|
||||
## Setup
|
||||
|
||||
**Beginner mode**: First-time users get a guided onboarding flow — select beginner mode at registration and the system walks you through AI, exchange, and strategy setup step by step.
|
||||
|
||||
**Advanced mode**:
|
||||
|
||||
1. **AI** — Add API keys or configure x402 wallet
|
||||
2. **Exchange** — Connect exchange API credentials
|
||||
3. **Strategy** — Build in Strategy Studio
|
||||
4. **Trader** — Combine AI + Exchange + Strategy
|
||||
5. **Trade** — Launch from the dashboard
|
||||
|
||||
Everything through the web UI at **http://127.0.0.1:3000**.
|
||||
|
||||
---
|
||||
|
||||
## Deploy to Server
|
||||
|
||||
**HTTP (quick):**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# Access via http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**HTTPS (Cloudflare):**
|
||||
|
||||
1. Add domain to [Cloudflare](https://dash.cloudflare.com) (free plan)
|
||||
2. A record → your server IP (Proxied)
|
||||
1. Add the domain to [Cloudflare](https://dash.cloudflare.com) (free plan)
|
||||
2. A record → server IP, proxied
|
||||
3. SSL/TLS → Flexible
|
||||
4. Set `TRANSPORT_ENCRYPTION=true` in `.env`
|
||||
4. `TRANSPORT_ENCRYPTION=true` in `.env`
|
||||
|
||||
---
|
||||
</details>
|
||||
|
||||
## Architecture
|
||||
<br/>
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Web Dashboard │
|
||||
│ React + TypeScript + TradingView │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
├──────────┬──────────┬──────────┬────────────────┤
|
||||
│ Strategy │ Telegram │
|
||||
│ Engine │ Agent │
|
||||
├──────────┴──────────┴──────────┴────────────────┤
|
||||
│ MCP AI Client Layer │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ API Key │ │ x402 │ │ │ │
|
||||
│ │ DeepSeek │ │ Claw402 │ │ │ │
|
||||
│ │ GPT,Claude │ │ │ │ │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectors │
|
||||
│ Binance · Bybit · OKX · Bitget · KuCoin · Gate │
|
||||
│ Hyperliquid · Aster DEX · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docs
|
||||
## Documentation
|
||||
|
||||
| | |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [Getting Started](docs/getting-started/README.md) | Deployment and exchange API guides |
|
||||
| [Architecture](docs/architecture/README.md) | System design and module index |
|
||||
| [Strategy Module](docs/architecture/STRATEGY_MODULE.md) | Coin selection, AI prompts, execution |
|
||||
| [FAQ](docs/faq/README.md) | Common questions |
|
||||
| [Getting Started](docs/getting-started/README.md) | Deployment guide |
|
||||
| [FAQ](docs/guides/faq.en.md) | Common questions |
|
||||
| [Troubleshooting](docs/guides/TROUBLESHOOTING.md) | Diagnosing common issues |
|
||||
|
||||
---
|
||||
## Community
|
||||
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [Live dashboard](https://vergex.trade/explore)
|
||||
|
||||
## Contributing
|
||||
|
||||
See [Contributing Guide](CONTRIBUTING.md) · [Code of Conduct](CODE_OF_CONDUCT.md) · [Security Policy](SECURITY.md)
|
||||
Code, documentation, translations, and bug reports are all welcome — see the [Contributing Guide](CONTRIBUTING.md), [Code of Conduct](CODE_OF_CONDUCT.md), and [Security Policy](SECURITY.md).
|
||||
|
||||
### Contributor Airdrop Program
|
||||
|
||||
All contributions are tracked. When NOFX generates revenue, contributors receive airdrops.
|
||||
|
||||
**[Pinned Issues](https://github.com/NoFxAiOS/nofx/issues) get the highest rewards.**
|
||||
NOFX tracks meaningful contributions and intends to reward contributors as the ecosystem grows. Priority issues carry higher weight.
|
||||
|
||||
| Contribution | Weight |
|
||||
| :---------------- | :----: |
|
||||
@@ -320,21 +249,9 @@ All contributions are tracked. When NOFX generates revenue, contributors receive
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
|
||||
| | |
|
||||
| :-------- | :---------------------------------------------------- |
|
||||
| Website | [nofxai.com](https://nofxai.com) |
|
||||
| Dashboard | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API Docs | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
|
||||
> **Risk Warning**: AI auto-trading carries significant risks. Recommended for learning/research or small amounts only.
|
||||
|
||||
---
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## Sponsors
|
||||
|
||||
@@ -352,8 +269,14 @@ All contributions are tracked. When NOFX generates revenue, contributors receive
|
||||
|
||||
[Become a sponsor](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
<br/>
|
||||
|
||||
If NOFX is useful to you, a star helps other traders find it.
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>Automated trading involves substantial risk. AI-driven strategies are experimental and can lose money. Size positions appropriately, understand each venue, and never trade funds you cannot afford to lose. Full [disclaimer](DISCLAIMER.md).</sub>
|
||||
|
||||
825
agent/agent.go
825
agent/agent.go
@@ -1,825 +0,0 @@
|
||||
// Package agent implements the NOFXi Agent Core.
|
||||
//
|
||||
// Architecture: ALL user messages go to the LLM. The LLM understands intent
|
||||
// and calls tools to execute actions. No regex routing, no pattern matching.
|
||||
// The LLM IS the brain — just like how OpenClaw works.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"nofx/manager"
|
||||
"nofx/market"
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
type Agent struct {
|
||||
traderManager *manager.TraderManager
|
||||
store *store.Store
|
||||
aiClient mcp.AIClient
|
||||
config *Config
|
||||
sentinel *Sentinel
|
||||
brain *Brain
|
||||
scheduler *Scheduler
|
||||
logger *slog.Logger
|
||||
history *chatHistory
|
||||
pending *pendingTrades
|
||||
stopCh chan struct{} // signals background goroutines to stop
|
||||
stopOnce sync.Once
|
||||
NotifyFunc func(userID int64, text string) error
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Language string `json:"language"`
|
||||
WatchSymbols []string `json:"watch_symbols"`
|
||||
EnableBriefs bool `json:"enable_briefs"`
|
||||
EnableNews bool `json:"enable_news"`
|
||||
EnableSentinel bool `json:"enable_sentinel"`
|
||||
BriefTimes []int `json:"brief_times"`
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
Language: "zh", WatchSymbols: []string{"BTCUSDT", "ETHUSDT", "SOLUSDT"},
|
||||
EnableBriefs: true, EnableNews: true, EnableSentinel: true, BriefTimes: []int{8, 20},
|
||||
}
|
||||
}
|
||||
|
||||
func New(tm *manager.TraderManager, st *store.Store, cfg *Config, logger *slog.Logger) *Agent {
|
||||
if cfg == nil {
|
||||
cfg = DefaultConfig()
|
||||
}
|
||||
return &Agent{traderManager: tm, store: st, config: cfg, logger: logger, history: newChatHistory(100), pending: newPendingTrades(), stopCh: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (a *Agent) SetAIClient(c mcp.AIClient) { a.aiClient = c }
|
||||
|
||||
func (a *Agent) ensureHistory() {
|
||||
if a.history == nil {
|
||||
a.history = newChatHistory(100)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) log() *slog.Logger {
|
||||
if a != nil && a.logger != nil {
|
||||
return a.logger
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
func (a *Agent) EnsureAIClient() {
|
||||
a.ensureAIClientForStoreUser("default")
|
||||
}
|
||||
|
||||
func (a *Agent) ensureAIClientForStoreUser(storeUserID string) {
|
||||
if storeUserID == "" {
|
||||
storeUserID = "default"
|
||||
}
|
||||
if a.store != nil {
|
||||
if client, modelName, ok := a.loadAIClientFromStoreUser(storeUserID); ok {
|
||||
a.aiClient = client
|
||||
a.log().Info("agent AI client ready", "store_user_id", storeUserID, "model", modelName)
|
||||
return
|
||||
}
|
||||
}
|
||||
if a.aiClient != nil {
|
||||
a.log().Warn("clearing stale AI client for store user", "store_user_id", storeUserID)
|
||||
a.aiClient = nil
|
||||
}
|
||||
a.log().Warn("no AI client — agent will have limited capabilities", "store_user_id", storeUserID)
|
||||
}
|
||||
|
||||
func (a *Agent) loadAIClientFromStoreUser(storeUserID string) (mcp.AIClient, string, bool) {
|
||||
if a.store == nil {
|
||||
a.log().Warn("cannot load AI client: store unavailable", "store_user_id", storeUserID)
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
if storeUserID == "" {
|
||||
storeUserID = "default"
|
||||
}
|
||||
|
||||
model, err := a.store.AIModel().GetDefault(storeUserID)
|
||||
if err != nil || model == nil {
|
||||
a.log().Warn("no enabled AI model found for store user", "store_user_id", storeUserID, "error", err)
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
a.log().Info(
|
||||
"agent selected AI model config",
|
||||
"store_user_id", storeUserID,
|
||||
"model_id", model.ID,
|
||||
"provider", model.Provider,
|
||||
"enabled", model.Enabled,
|
||||
"has_api_key", len(model.APIKey) > 0,
|
||||
"custom_api_url", strings.TrimSpace(model.CustomAPIURL),
|
||||
"custom_model_name", strings.TrimSpace(model.CustomModelName),
|
||||
)
|
||||
|
||||
apiKey := string(model.APIKey)
|
||||
customAPIURL := strings.TrimSpace(model.CustomAPIURL)
|
||||
modelName := strings.TrimSpace(model.CustomModelName)
|
||||
provider := strings.ToLower(strings.TrimSpace(model.Provider))
|
||||
|
||||
// Use the provider registry for providers like claw402 that have their own
|
||||
// client implementation (x402 payment, custom auth, etc.).
|
||||
if client := mcp.NewAIClientByProvider(provider); client != nil {
|
||||
if modelName == "" {
|
||||
modelName = model.ID
|
||||
}
|
||||
client.SetAPIKey(apiKey, customAPIURL, modelName)
|
||||
return client, modelName, true
|
||||
}
|
||||
|
||||
customAPIURL, modelName = resolveModelRuntimeConfig(provider, customAPIURL, modelName, model.ID)
|
||||
if apiKey == "" || customAPIURL == "" {
|
||||
a.log().Warn(
|
||||
"enabled AI model is incomplete",
|
||||
"store_user_id", storeUserID,
|
||||
"model_id", model.ID,
|
||||
"provider", model.Provider,
|
||||
"has_api_key", apiKey != "",
|
||||
"has_custom_api_url", customAPIURL != "",
|
||||
)
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: 60 * time.Second}
|
||||
client := mcp.NewClient(mcp.WithHTTPClient(httpClient))
|
||||
name := modelName
|
||||
client.SetAPIKey(apiKey, customAPIURL, name)
|
||||
return client, name, true
|
||||
}
|
||||
|
||||
func resolveModelRuntimeConfig(provider, customAPIURL, customModelName, fallbackModelID string) (string, string) {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
customAPIURL = strings.TrimSpace(customAPIURL)
|
||||
customModelName = strings.TrimSpace(customModelName)
|
||||
fallbackModelID = strings.TrimSpace(fallbackModelID)
|
||||
|
||||
type providerDefaults struct {
|
||||
url string
|
||||
model string
|
||||
}
|
||||
defaults := map[string]providerDefaults{
|
||||
"deepseek": {url: "https://api.deepseek.com/v1", model: "deepseek-chat"},
|
||||
"qwen": {url: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "qwen3-max"},
|
||||
"openai": {url: "https://api.openai.com/v1", model: "gpt-5.2"},
|
||||
"claude": {url: "https://api.anthropic.com/v1", model: "claude-opus-4-6"},
|
||||
"gemini": {url: "https://generativelanguage.googleapis.com/v1beta/openai", model: "gemini-3-pro-preview"},
|
||||
"grok": {url: "https://api.x.ai/v1", model: "grok-3-latest"},
|
||||
"kimi": {url: "https://api.moonshot.ai/v1", model: "moonshot-v1-auto"},
|
||||
"minimax": {url: "https://api.minimax.chat/v1", model: "MiniMax-M2.5"},
|
||||
}
|
||||
|
||||
if customAPIURL == "" {
|
||||
if cfg, ok := defaults[provider]; ok {
|
||||
customAPIURL = cfg.url
|
||||
}
|
||||
}
|
||||
if customModelName == "" {
|
||||
if cfg, ok := defaults[provider]; ok {
|
||||
customModelName = cfg.model
|
||||
}
|
||||
}
|
||||
if customModelName == "" {
|
||||
customModelName = fallbackModelID
|
||||
}
|
||||
return customAPIURL, customModelName
|
||||
}
|
||||
|
||||
func (a *Agent) Start() {
|
||||
a.logger.Info("starting NOFXi agent...")
|
||||
a.EnsureAIClient()
|
||||
|
||||
if a.config.EnableSentinel {
|
||||
a.sentinel = NewSentinel(a.config.WatchSymbols, a.handleSignal, a.logger)
|
||||
a.sentinel.Start()
|
||||
}
|
||||
a.brain = NewBrain(a, a.logger)
|
||||
if a.config.EnableNews {
|
||||
a.brain.StartNewsScan(5 * time.Minute)
|
||||
}
|
||||
if a.config.EnableBriefs {
|
||||
a.brain.StartMarketBriefs(a.config.BriefTimes)
|
||||
}
|
||||
a.scheduler = NewScheduler(a, a.logger)
|
||||
a.scheduler.Start(context.Background())
|
||||
|
||||
a.logger.Info("NOFXi agent is online 🚀")
|
||||
}
|
||||
|
||||
func (a *Agent) Stop() {
|
||||
// Signal all background goroutines (e.g. chat-history-cleanup) to exit.
|
||||
a.stopOnce.Do(func() { close(a.stopCh) })
|
||||
if a.sentinel != nil {
|
||||
a.sentinel.Stop()
|
||||
}
|
||||
if a.brain != nil {
|
||||
a.brain.Stop()
|
||||
}
|
||||
if a.scheduler != nil {
|
||||
a.scheduler.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMessage — the core. Everything goes through the LLM.
|
||||
func (a *Agent) HandleMessage(ctx context.Context, userID int64, text string) (string, error) {
|
||||
a.EnsureAIClient()
|
||||
return a.handleMessageForStoreUser(ctx, "default", userID, text)
|
||||
}
|
||||
|
||||
// HandleMessageForStoreUser is like HandleMessage but stores setup artifacts
|
||||
// (exchange/model) under the provided authenticated store user ID.
|
||||
func (a *Agent) HandleMessageForStoreUser(ctx context.Context, storeUserID string, userID int64, text string) (string, error) {
|
||||
return a.handleMessageForStoreUser(ctx, storeUserID, userID, text)
|
||||
}
|
||||
|
||||
func (a *Agent) handleMessageForStoreUser(ctx context.Context, storeUserID string, userID int64, text string) (string, error) {
|
||||
a.ensureAIClientForStoreUser(storeUserID)
|
||||
|
||||
lang := a.config.Language
|
||||
if strings.HasPrefix(text, "[lang:") {
|
||||
if end := strings.Index(text, "] "); end > 0 {
|
||||
lang = text[6:end]
|
||||
text = text[end+2:]
|
||||
}
|
||||
}
|
||||
|
||||
a.logger.Info("message", "user_id", userID, "text", text)
|
||||
|
||||
// Only keep a tiny command surface outside the planner.
|
||||
if text == "/status" {
|
||||
return a.handleStatus(lang), nil
|
||||
}
|
||||
if text == "/clear" {
|
||||
a.history.Clear(userID)
|
||||
a.clearTaskState(userID)
|
||||
a.clearExecutionState(userID)
|
||||
if lang == "zh" {
|
||||
return "🧹 对话记忆已清除。", nil
|
||||
}
|
||||
return "🧹 Conversation history cleared.", nil
|
||||
}
|
||||
if reply, handled := a.handleTradeConfirmation(ctx, userID, text, lang); handled {
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// Everything else goes through the planner and tool system.
|
||||
return a.thinkAndAct(ctx, storeUserID, userID, lang, text)
|
||||
}
|
||||
|
||||
// HandleMessageStream is like HandleMessage but streams the final LLM response via SSE.
|
||||
// onEvent is called with (eventType, data) — see StreamEvent* constants.
|
||||
// Non-streamable responses (commands, trade confirmations) return immediately without events.
|
||||
func (a *Agent) HandleMessageStream(ctx context.Context, userID int64, text string, onEvent func(event, data string)) (string, error) {
|
||||
a.EnsureAIClient()
|
||||
return a.handleMessageStreamForStoreUser(ctx, "default", userID, text, onEvent)
|
||||
}
|
||||
|
||||
// HandleMessageStreamForStoreUser mirrors HandleMessageForStoreUser for SSE responses.
|
||||
func (a *Agent) HandleMessageStreamForStoreUser(ctx context.Context, storeUserID string, userID int64, text string, onEvent func(event, data string)) (string, error) {
|
||||
return a.handleMessageStreamForStoreUser(ctx, storeUserID, userID, text, onEvent)
|
||||
}
|
||||
|
||||
func (a *Agent) handleMessageStreamForStoreUser(ctx context.Context, storeUserID string, userID int64, text string, onEvent func(event, data string)) (string, error) {
|
||||
a.ensureAIClientForStoreUser(storeUserID)
|
||||
|
||||
lang := a.config.Language
|
||||
if strings.HasPrefix(text, "[lang:") {
|
||||
if end := strings.Index(text, "] "); end > 0 {
|
||||
lang = text[6:end]
|
||||
text = text[end+2:]
|
||||
}
|
||||
}
|
||||
|
||||
a.logger.Info("message (stream)", "user_id", userID, "text", text)
|
||||
|
||||
if text == "/status" {
|
||||
return a.handleStatus(lang), nil
|
||||
}
|
||||
if text == "/clear" {
|
||||
a.history.Clear(userID)
|
||||
a.clearTaskState(userID)
|
||||
a.clearExecutionState(userID)
|
||||
if lang == "zh" {
|
||||
return "🧹 对话记忆已清除。", nil
|
||||
}
|
||||
return "🧹 Conversation history cleared.", nil
|
||||
}
|
||||
if reply, handled := a.handleTradeConfirmation(ctx, userID, text, lang); handled {
|
||||
if onEvent != nil {
|
||||
onEvent(StreamEventDelta, reply)
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
return a.thinkAndActStream(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
}
|
||||
|
||||
// StreamEvent types sent via SSE to the frontend.
|
||||
const (
|
||||
StreamEventPlanning = "planning"
|
||||
StreamEventPlan = "plan"
|
||||
StreamEventStepStart = "step_start"
|
||||
StreamEventStepComplete = "step_complete"
|
||||
StreamEventReplan = "replan"
|
||||
StreamEventTool = "tool" // Tool is being called (shows status to user)
|
||||
StreamEventDelta = "delta" // Text chunk from LLM streaming
|
||||
StreamEventDone = "done" // Stream complete
|
||||
StreamEventError = "error" // Error occurred
|
||||
)
|
||||
|
||||
// buildSystemPrompt creates the system prompt that makes NOFXi behave like a real agent.
|
||||
func (a *Agent) buildSystemPrompt(lang string) string {
|
||||
// Gather live system state
|
||||
traderInfo := a.getTradersSummary()
|
||||
watchlist := ""
|
||||
if a.sentinel != nil {
|
||||
watchlist = a.sentinel.FormatWatchlist(lang)
|
||||
}
|
||||
skillCatalog := skillCatalogPrompt(lang)
|
||||
|
||||
if lang == "zh" {
|
||||
return fmt.Sprintf(`你是 NOFXi,一个专业的 AI 交易 Agent。你不是一个简单的聊天机器人——你是用户的交易伙伴。
|
||||
|
||||
## 你的核心能力
|
||||
1. **市场分析** — 加密货币(BTC/ETH/SOL等)有实时数据,A股/港股/美股/外汇你可以基于知识分析
|
||||
2. **交易管理** — 查看持仓、余额、交易历史、Trader 状态
|
||||
3. **策略建议** — 根据用户需求制定交易策略
|
||||
4. **策略模板管理** — 创建、查看、修改、删除、激活策略模板
|
||||
5. **风险管理** — 评估风险、建议止损止盈
|
||||
6. **配置引导** — 用户说"开始配置"时引导配置交易所和AI模型
|
||||
|
||||
## 当前系统状态
|
||||
%s
|
||||
%s
|
||||
|
||||
## 数据说明(极其重要,违反即失职!)
|
||||
- 加密货币(BTC/ETH等):交易所实时数据,标注 [Real-time]
|
||||
- A股/港股/美股:**必须调用 search_stock 工具**获取实时行情。不调工具就没有数据。
|
||||
- 美股盘前盘后:search_stock 返回的 quote 中 ext_price/ext_change_pct/ext_time
|
||||
- 外汇/指数期货:当前没有数据源,如实告知
|
||||
|
||||
### 铁律:禁止编造任何价格!
|
||||
- **你的训练数据中的价格全部过时,不可使用**
|
||||
- **没有通过工具获取的价格 = 你不知道 = 不能说**
|
||||
- 用户问多只股票的盘前数据?→ 对每只股票调用 search_stock 工具
|
||||
- 用户问"盘前概览"?→ 调用 search_stock 查主要股票(AAPL、TSLA、NVDA、MSFT、GOOGL、AMZN、META等),用真实数据回答
|
||||
- **绝对不允许**不调工具就给出具体价格数字(如 $421.85)
|
||||
- 如果某只股票 search_stock 查不到数据,就说"暂时无法获取该股票数据"
|
||||
- 指数期货(纳指、标普、道琼斯期货)我们目前没有数据源,直接说"暂不支持指数期货数据"
|
||||
|
||||
## 工具使用
|
||||
你可以调用以下工具来执行操作:
|
||||
- **search_stock** — 搜索股票(支持中文名、英文名、代码)。当用户提到你不认识的股票时,先用这个工具搜索。
|
||||
- **execute_trade** — 下单交易(加密货币或美股)。美股:open_long=买入,close_long=卖出。调用后创建待确认订单,用户需回复"确认 trade_xxx"。
|
||||
- **get_positions** — 查看当前所有持仓(加密货币 + 股票)
|
||||
- **get_balance** — 查看账户余额
|
||||
- **get_market_price** — 获取实时价格(加密货币或股票代码)
|
||||
- **get_exchange_configs / manage_exchange_config** — 查看、新增、修改、删除交易所绑定配置
|
||||
- **get_model_configs / manage_model_config** — 查看、新增、修改、删除 AI 模型配置
|
||||
- **get_strategies / manage_strategy** — 查看、新增、修改、删除、激活、复制策略模板
|
||||
- **manage_trader** — 查看、新增、修改、删除、启动、停止交易员
|
||||
|
||||
### 配置、策略与交易员管理规则
|
||||
- 当用户要求创建、修改、删除、激活、复制策略模板时,优先使用 get_strategies / manage_strategy
|
||||
- **策略模板本身是独立资源,不默认依赖交易所或 AI 模型**
|
||||
- 只有当用户要求“运行策略 / 创建交易员 / 把策略部署到账户”时,才需要进一步关联交易所、模型或 trader
|
||||
- 当用户要求配置交易所、绑定 API Key、修改交易所账户时,优先使用 manage_exchange_config
|
||||
- 当用户要求配置大模型、设置 API Key、切换模型、修改模型地址时,优先使用 manage_model_config
|
||||
- 当用户要求创建、修改、删除、启动、停止交易员时,优先使用 manage_trader
|
||||
- 如果缺少必要字段,先追问缺失信息,再调用工具
|
||||
- **在这些工具存在时,不要说“系统没有这个能力”**
|
||||
- 对敏感信息(API Key、Secret、Private Key)只保存,不要在最终回复中完整回显
|
||||
|
||||
%s
|
||||
|
||||
### 交易安全规则
|
||||
- 用户明确要求交易时才调用 execute_trade
|
||||
- 分析和建议不需要调用工具,直接回复即可
|
||||
- 交易确认信息要清晰展示:品种、方向、数量、杠杆
|
||||
- 提醒用户确认命令格式
|
||||
|
||||
### 数据真实性规则(极其重要!)
|
||||
- **持仓信息必须且只能通过 get_positions 工具获取**,绝对禁止编造持仓
|
||||
- **余额信息必须且只能通过 get_balance 工具获取**,绝对禁止编造余额
|
||||
- 如果用户问持仓但 get_positions 返回空,就说"当前没有持仓",不要编造
|
||||
- 如果工具返回 error(如未配置交易所),如实告知用户
|
||||
- **你不知道用户持有什么股票/币种,除非工具返回了数据**
|
||||
- 查股票行情 ≠ 用户持有该股票。不要混淆"查价格"和"有持仓"
|
||||
|
||||
## 行为准则
|
||||
- 简洁、专业、有观点。不说废话。
|
||||
- 用户问什么答什么,不要推销配置。
|
||||
- 有实时数据时给具体价位,没有时给策略框架和思路。
|
||||
- **诚实是第一原则** — 不确定就说不确定,没数据就说没数据。绝不编造。
|
||||
- 用交易相关的 emoji 让回复更直观。
|
||||
- 用中文回复。
|
||||
|
||||
当前时间: %s`, traderInfo, watchlist, skillCatalog, time.Now().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`You are NOFXi, a professional AI trading agent. Not a chatbot — a trading partner.
|
||||
|
||||
## Capabilities
|
||||
1. Market analysis — crypto with real-time data, stocks/forex with knowledge
|
||||
2. Trade management — positions, balance, history, trader status
|
||||
3. Strategy — build trading strategies based on user needs
|
||||
4. Strategy template management — create, inspect, update, delete, and activate strategy templates
|
||||
5. Risk management — assess risk, suggest stop-loss/take-profit
|
||||
6. Setup — guide exchange/AI configuration when user asks
|
||||
|
||||
## Current System State
|
||||
%s
|
||||
%s
|
||||
|
||||
## Data Notice (CRITICAL — violating this is unacceptable!)
|
||||
- Crypto (BTC/ETH): Exchange real-time data, marked [Real-time]
|
||||
- Stocks: You MUST call search_stock tool to get real-time quotes. No tool call = no data.
|
||||
- US stocks pre/after-hours: ext_price/ext_change_pct/ext_time in search_stock results
|
||||
- Forex/Index futures: No data source currently — tell user honestly
|
||||
|
||||
### ABSOLUTE RULE: NEVER fabricate any price!
|
||||
- Your training data prices are ALL outdated and MUST NOT be used
|
||||
- No tool result = you don't know = you cannot state a price
|
||||
- User asks multiple stocks? → Call search_stock for EACH one
|
||||
- User asks "pre-market overview"? → Call search_stock for major stocks (AAPL, TSLA, NVDA, MSFT, GOOGL, AMZN, META etc.) and use real data
|
||||
- NEVER output a specific price number (like $421.85) without a tool having returned it
|
||||
- If search_stock fails for a stock, say "unable to fetch data for this stock"
|
||||
- Index futures (NDX, SPX, DJI futures) — we have no data source, say "index futures not supported yet"
|
||||
|
||||
## Tools
|
||||
You can call these tools to take action:
|
||||
- **search_stock** — Search for stocks by name, ticker, or code. Covers A-share, HK, and US markets. Use when the user mentions an unknown stock.
|
||||
- **execute_trade** — Place a trade order (crypto or US stocks). For stocks: open_long=buy, close_long=sell. Creates a pending order that requires user confirmation.
|
||||
- **get_positions** — View all current open positions (crypto + stocks)
|
||||
- **get_balance** — View account balance and equity
|
||||
- **get_market_price** — Get real-time price from the exchange (crypto or stock symbol)
|
||||
- **get_exchange_configs / manage_exchange_config** — View, create, update, and delete exchange bindings
|
||||
- **get_model_configs / manage_model_config** — View, create, update, and delete AI model bindings
|
||||
- **get_strategies / manage_strategy** — View, create, update, delete, activate, and duplicate strategy templates
|
||||
- **manage_trader** — List, create, update, delete, start, and stop traders
|
||||
|
||||
### Configuration, Strategy, and Trader Rules
|
||||
- When the user wants to create, edit, delete, activate, or duplicate a strategy template, prefer get_strategies / manage_strategy
|
||||
- **A strategy template is an independent asset and does not require exchange or model bindings by default**
|
||||
- Only ask for exchange/model/trader details when the user wants to run, deploy, or attach a strategy to a trader
|
||||
- When the user wants to bind or edit an exchange account, prefer manage_exchange_config
|
||||
- When the user wants to bind or edit an AI model, prefer manage_model_config
|
||||
- When the user wants to create, edit, delete, start, or stop a trader, prefer manage_trader
|
||||
- If required fields are missing, ask a focused follow-up question first, then call the tool
|
||||
- **Do not claim the system lacks these capabilities when the tools exist**
|
||||
- For secrets such as API keys, secrets, and private keys: store them, but never echo them back in full
|
||||
|
||||
%s
|
||||
|
||||
### Trade Safety Rules
|
||||
- Only call execute_trade when user explicitly requests a trade
|
||||
- Analysis and advice don't need tools — just reply directly
|
||||
- Show trade details clearly: symbol, direction, quantity, leverage
|
||||
- Remind user of the confirmation command format
|
||||
|
||||
### Data Truthfulness Rules (CRITICAL!)
|
||||
- **Position data MUST come from get_positions tool only** — NEVER fabricate positions
|
||||
- **Balance data MUST come from get_balance tool only** — NEVER fabricate balances
|
||||
- If get_positions returns empty, say "no open positions" — do NOT make up holdings
|
||||
- If a tool returns an error (e.g. no exchange configured), tell the user honestly
|
||||
- **You do NOT know what the user holds unless a tool tells you**
|
||||
- Checking a stock price ≠ user owns that stock. Never confuse "quote lookup" with "holding"
|
||||
|
||||
## Behavior
|
||||
- Concise, professional, opinionated. No fluff.
|
||||
- Answer what's asked. Don't push setup.
|
||||
- With real-time data: give specific levels. Without: give strategy frameworks.
|
||||
- **Honesty is rule #1** — uncertain = say uncertain, no data = say no data.
|
||||
- Use trading emojis.
|
||||
|
||||
Current time: %s`, traderInfo, watchlist, skillCatalog, time.Now().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
// gatherContext collects real-time market data relevant to the user's message.
|
||||
func (a *Agent) gatherContext(text string) string {
|
||||
var parts []string
|
||||
upper := strings.ToUpper(text)
|
||||
|
||||
// Crypto — detect symbols dynamically
|
||||
// 1. Check known popular symbols (fast path)
|
||||
// 2. Extract any "XXXUSDT" pattern from text (catches arbitrary pairs)
|
||||
knownSymbols := []string{
|
||||
"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "ADA", "AVAX", "DOT", "LINK",
|
||||
"PEPE", "SHIB", "ARB", "OP", "SUI", "APT", "SEI", "TIA", "JUP", "WIF",
|
||||
"NEAR", "ATOM", "FTM", "MATIC", "INJ", "RENDER", "FET", "TAO", "WLD",
|
||||
"AAVE", "UNI", "LDO", "MKR", "CRV", "PENDLE", "ENA", "ONDO", "TRUMP",
|
||||
}
|
||||
matched := make(map[string]bool)
|
||||
for _, sym := range knownSymbols {
|
||||
if strings.Contains(upper, sym) {
|
||||
matched[sym] = true
|
||||
}
|
||||
}
|
||||
// Also extract "XXXUSDT" patterns for coins not in the known list
|
||||
for _, word := range strings.Fields(upper) {
|
||||
word = strings.Trim(word, ".,!?;:()[]{}\"'")
|
||||
if strings.HasSuffix(word, "USDT") && len(word) > 4 && len(word) <= 15 {
|
||||
sym := strings.TrimSuffix(word, "USDT")
|
||||
if len(sym) >= 2 && len(sym) <= 10 {
|
||||
matched[sym] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Collect and sort matched symbols for deterministic selection
|
||||
sortedSymbols := make([]string, 0, len(matched))
|
||||
for sym := range matched {
|
||||
sortedSymbols = append(sortedSymbols, sym)
|
||||
}
|
||||
sort.Strings(sortedSymbols)
|
||||
|
||||
// Cap at 5 symbols to avoid slow context gathering
|
||||
count := 0
|
||||
for _, sym := range sortedSymbols {
|
||||
if count >= 5 {
|
||||
break
|
||||
}
|
||||
md, err := market.Get(sym + "USDT")
|
||||
if err == nil && md.CurrentPrice > 0 {
|
||||
parts = append(parts, fmt.Sprintf("[%s/USDT Real-time]\nPrice: $%.4f | 1h: %+.2f%% | 4h: %+.2f%% | RSI7: %.1f | EMA20: %.4f | MACD: %.6f | Funding: %.4f%%",
|
||||
sym, md.CurrentPrice, md.PriceChange1h, md.PriceChange4h, md.CurrentRSI7, md.CurrentEMA20, md.CurrentMACD, md.FundingRate*100))
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
// A-share / stocks — only call Sina API when text likely references stocks.
|
||||
// Skip for purely crypto conversations to avoid unnecessary external API calls.
|
||||
if looksLikeStockQuery(text) {
|
||||
stockCode, stockName := resolveStockCodeDynamic(text)
|
||||
if stockCode != "" {
|
||||
quote, err := fetchStockQuote(stockCode)
|
||||
if err == nil && quote.Price > 0 {
|
||||
parts = append(parts, fmt.Sprintf("[%s(%s) Real-time A-share Data]\n%s", quote.Name, quote.Code, formatStockQuote(quote)))
|
||||
} else if err != nil {
|
||||
a.logger.Error("fetch stock quote", "code", stockCode, "name", stockName, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trader positions
|
||||
if a.traderManager != nil {
|
||||
for _, t := range a.traderManager.GetAllTraders() {
|
||||
positions, err := t.GetPositions()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, p := range positions {
|
||||
size := toFloat(p["size"])
|
||||
if size == 0 {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("[Position] %s %s: size=%.4f entry=$%.4f mark=$%.4f pnl=$%.2f",
|
||||
p["symbol"], p["side"], size, toFloat(p["entryPrice"]), toFloat(p["markPrice"]), toFloat(p["unrealizedPnl"])))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func (a *Agent) getTradersSummary() string {
|
||||
if a.traderManager == nil {
|
||||
return "Traders: none configured"
|
||||
}
|
||||
traders := a.traderManager.GetAllTraders()
|
||||
if len(traders) == 0 {
|
||||
return "Traders: none configured"
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for id, t := range traders {
|
||||
s := t.GetStatus()
|
||||
running, _ := s["is_running"].(bool)
|
||||
status := "stopped"
|
||||
if running {
|
||||
status = "running"
|
||||
}
|
||||
tid := id
|
||||
if len(tid) > 8 {
|
||||
tid = tid[:8]
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("• %s [%s] %s | %s", t.GetName(), tid, status, t.GetExchange()))
|
||||
}
|
||||
return "Traders:\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (a *Agent) handleStatus(L string) string {
|
||||
tc, rc := 0, 0
|
||||
if a.traderManager != nil {
|
||||
all := a.traderManager.GetAllTraders()
|
||||
tc = len(all)
|
||||
for _, t := range all {
|
||||
if s := t.GetStatus(); s["is_running"] == true {
|
||||
rc++
|
||||
}
|
||||
}
|
||||
}
|
||||
wc := 0
|
||||
if a.sentinel != nil {
|
||||
wc = a.sentinel.SymbolCount()
|
||||
}
|
||||
ai := "❌"
|
||||
if a.aiClient != nil {
|
||||
ai = "✅"
|
||||
}
|
||||
return fmt.Sprintf(a.msg(L, "status"), rc, tc, wc, ai, time.Now().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
// noAIFallback — when no AI is available, still try to be useful.
|
||||
func (a *Agent) noAIFallback(lang, text string) (string, error) {
|
||||
upper := strings.ToUpper(text)
|
||||
|
||||
// Try to provide market data directly
|
||||
for _, sym := range []string{"BTC", "ETH", "SOL", "BNB", "XRP", "DOGE"} {
|
||||
if strings.Contains(upper, sym) {
|
||||
md, err := market.Get(sym + "USDT")
|
||||
if err == nil {
|
||||
return fmt.Sprintf("📊 *%s/USDT*\n\n%s\n\n💡 配置 AI 模型后我能给你更深度的分析。发送 *开始配置* 开始。", sym, market.Format(md)), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if asking about positions/balance
|
||||
if strings.Contains(text, "持仓") || strings.Contains(upper, "POSITION") {
|
||||
return a.queryPositionsDirect(lang)
|
||||
}
|
||||
if strings.Contains(text, "余额") || strings.Contains(upper, "BALANCE") {
|
||||
return a.queryBalancesDirect(lang)
|
||||
}
|
||||
|
||||
if lang == "zh" {
|
||||
return "🤖 我是 NOFXi。配置 AI 模型后我就能理解你的任何问题——分析股票、制定策略、管理交易。\n\n现在可用:\n• 加密货币实时行情(试试「BTC」)\n• `/status` 系统状态\n\n发送 *开始配置* 配置 AI 模型。", nil
|
||||
}
|
||||
return "🤖 I'm NOFXi. Configure an AI model and I can understand anything — analyze stocks, build strategies, manage trades.\n\nAvailable now:\n• Crypto real-time data (try 'BTC')\n• `/status` system status\n\nSend *setup* to configure AI.", nil
|
||||
}
|
||||
|
||||
func (a *Agent) aiServiceFailure(lang string, err error) (string, error) {
|
||||
reason := "unknown error"
|
||||
if err != nil {
|
||||
reason = summarizeObservation(err.Error())
|
||||
}
|
||||
a.logger.Error("AI service call failed", "error", reason)
|
||||
if lang == "zh" {
|
||||
return fmt.Sprintf("当前 AI 服务调用失败:%s\n\n这不是“未配置模型”。更可能是模型服务余额不足、接口报错或超时。请检查当前启用模型的 API 状态后再试。", reason), nil
|
||||
}
|
||||
return fmt.Sprintf("The AI service call failed: %s\n\nThis is not a missing-model issue. The active model provider likely returned an error, timed out, or has insufficient balance. Please check the active model API and try again.", reason), nil
|
||||
}
|
||||
|
||||
func (a *Agent) queryPositionsDirect(L string) (string, error) {
|
||||
if a.traderManager == nil {
|
||||
return a.msg(L, "no_traders"), nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("📊 *Positions*\n\n")
|
||||
hasAny := false
|
||||
for id, t := range a.traderManager.GetAllTraders() {
|
||||
positions, err := t.GetPositions()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, p := range positions {
|
||||
size := toFloat(p["size"])
|
||||
if size == 0 {
|
||||
continue
|
||||
}
|
||||
hasAny = true
|
||||
pnl := toFloat(p["unrealizedPnl"])
|
||||
e := "🟢"
|
||||
if pnl < 0 {
|
||||
e = "🔴"
|
||||
}
|
||||
tid := id
|
||||
if len(tid) > 8 {
|
||||
tid = tid[:8]
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s *%s* %s — $%.2f | Trader: %s\n", e, p["symbol"], p["side"], pnl, tid))
|
||||
}
|
||||
}
|
||||
if !hasAny {
|
||||
return a.msg(L, "no_positions"), nil
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (a *Agent) queryBalancesDirect(L string) (string, error) {
|
||||
if a.traderManager == nil {
|
||||
return a.msg(L, "no_traders"), nil
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("💰 *Balance*\n\n")
|
||||
for id, t := range a.traderManager.GetAllTraders() {
|
||||
info, err := t.GetAccountInfo()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tid := id
|
||||
if len(tid) > 8 {
|
||||
tid = tid[:8]
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("*%s* (%s): $%.2f\n", t.GetName(), tid, toFloat(info["total_equity"])))
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleSignal(sig Signal) {
|
||||
if a.brain != nil {
|
||||
a.brain.HandleSignal(sig)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) notifyAll(text string) {
|
||||
if a.NotifyFunc != nil {
|
||||
a.NotifyFunc(0, text)
|
||||
}
|
||||
}
|
||||
|
||||
// looksLikeStockQuery returns true if the text likely references stocks rather
|
||||
// than being a pure crypto/general query. This avoids hitting the Sina search
|
||||
// API on every single message (saves ~200ms latency + external API call).
|
||||
func looksLikeStockQuery(text string) bool {
|
||||
upper := strings.ToUpper(text)
|
||||
|
||||
// Check for known stock-related Chinese keywords
|
||||
stockKeywords := []string{
|
||||
"股", "A股", "港股", "美股", "股票", "涨停", "跌停", "大盘",
|
||||
"沪指", "深指", "恒指", "纳指", "标普", "道琼斯",
|
||||
"茅台", "比亚迪", "宁德", "腾讯", "阿里", "美团", "小米",
|
||||
"京东", "百度", "苹果", "特斯拉", "英伟达", "微软", "谷歌",
|
||||
"盘前", "盘后", "开盘", "收盘", "涨幅", "跌幅",
|
||||
}
|
||||
for _, kw := range stockKeywords {
|
||||
if strings.Contains(text, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for US stock ticker patterns (1-5 uppercase letters not matching crypto)
|
||||
for _, word := range strings.Fields(upper) {
|
||||
word = strings.Trim(word, ".,!?;:()[]{}\"'")
|
||||
if len(word) >= 1 && len(word) <= 5 {
|
||||
allLetter := true
|
||||
for _, c := range word {
|
||||
if c < 'A' || c > 'Z' {
|
||||
allLetter = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allLetter {
|
||||
// Check if it's in the known US ticker map
|
||||
if _, ok := usTickerMap[word]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for 6-digit A-share codes or 5-digit HK codes
|
||||
for _, w := range strings.Fields(text) {
|
||||
w = strings.TrimSpace(w)
|
||||
if len(w) == 5 || len(w) == 6 {
|
||||
if _, err := strconv.Atoi(w); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func toFloat(v interface{}) float64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x
|
||||
case float32:
|
||||
return float64(x)
|
||||
case int:
|
||||
return float64(x)
|
||||
case int64:
|
||||
return float64(x)
|
||||
case int32:
|
||||
return float64(x)
|
||||
case string:
|
||||
f, _ := strconv.ParseFloat(x, 64)
|
||||
return f
|
||||
case json.Number:
|
||||
f, _ := x.Float64()
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
func TestReadBackendLogEntriesReturnsRecentErrorLines(t *testing.T) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd() error = %v", err)
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
if err := os.Chdir(tmp); err != nil {
|
||||
t.Fatalf("Chdir(tmp) error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(wd)
|
||||
})
|
||||
|
||||
if err := os.MkdirAll("data", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(data) error = %v", err)
|
||||
}
|
||||
logPath := filepath.Join("data", "nofx_2099-01-01.log")
|
||||
content := strings.Join([]string{
|
||||
"04-19 13:00:00 [INFO] api/server.go:590 API server starting",
|
||||
"04-19 13:00:01 [ERRO] api/server.go:600 invalid signature for okx account",
|
||||
"04-19 13:00:02 [ERRO] agent/tools.go:123 model update failed: missing api key",
|
||||
}, "\n") + "\n"
|
||||
if err := os.WriteFile(logPath, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
path, entries, err := readBackendLogEntries(10, "model", true)
|
||||
if err != nil {
|
||||
t.Fatalf("readBackendLogEntries() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(path, "nofx_2099-01-01.log") {
|
||||
t.Fatalf("unexpected log path: %s", path)
|
||||
}
|
||||
if len(entries) != 1 || !strings.Contains(entries[0], "missing api key") {
|
||||
t.Fatalf("unexpected filtered entries: %#v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolGetBackendLogsRequiresOwnedTrader(t *testing.T) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd() error = %v", err)
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
if err := os.Chdir(tmp); err != nil {
|
||||
t.Fatalf("Chdir(tmp) error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(wd)
|
||||
})
|
||||
|
||||
if err := os.MkdirAll("data", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(data) error = %v", err)
|
||||
}
|
||||
logPath := filepath.Join("data", "nofx_2099-01-01.log")
|
||||
content := strings.Join([]string{
|
||||
"04-19 13:00:00 [INFO] api/server.go:590 API server starting",
|
||||
"04-19 13:00:01 [ERRO] trader/runtime.go:88 trader_id=trader-owned strategy execution failed",
|
||||
"04-19 13:00:02 [ERRO] trader/runtime.go:89 trader_id=trader-other strategy execution failed",
|
||||
}, "\n") + "\n"
|
||||
if err := os.WriteFile(logPath, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
a := newTestAgentWithStore(t)
|
||||
if err := a.store.Trader().Create(&store.Trader{
|
||||
ID: "trader-owned",
|
||||
UserID: "user-1",
|
||||
Name: "Owned Trader",
|
||||
AIModelID: "model-1",
|
||||
ExchangeID: "exchange-1",
|
||||
StrategyID: "strategy-1",
|
||||
InitialBalance: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("create owned trader: %v", err)
|
||||
}
|
||||
if err := a.store.Trader().Create(&store.Trader{
|
||||
ID: "trader-other",
|
||||
UserID: "user-2",
|
||||
Name: "Other Trader",
|
||||
AIModelID: "model-2",
|
||||
ExchangeID: "exchange-2",
|
||||
StrategyID: "strategy-2",
|
||||
InitialBalance: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("create other trader: %v", err)
|
||||
}
|
||||
|
||||
resp := a.toolGetBackendLogs("user-1", `{"trader_id":"trader-owned","limit":5}`)
|
||||
var okResult struct {
|
||||
TraderID string `json:"trader_id"`
|
||||
Entries []string `json:"entries"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(resp), &okResult); err != nil {
|
||||
t.Fatalf("unmarshal owned response: %v\nraw=%s", err, resp)
|
||||
}
|
||||
if okResult.TraderID != "trader-owned" || okResult.Count != 1 {
|
||||
t.Fatalf("unexpected owned response: %+v", okResult)
|
||||
}
|
||||
if len(okResult.Entries) != 1 || !strings.Contains(okResult.Entries[0], "trader-owned") {
|
||||
t.Fatalf("unexpected owned entries: %#v", okResult.Entries)
|
||||
}
|
||||
|
||||
resp = a.toolGetBackendLogs("user-1", `{"trader_id":"trader-other","limit":5}`)
|
||||
var denied struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(resp), &denied); err != nil {
|
||||
t.Fatalf("unmarshal denied response: %v\nraw=%s", err, resp)
|
||||
}
|
||||
if denied.Error != "trader not found for current user" {
|
||||
t.Fatalf("unexpected denied response: %+v", denied)
|
||||
}
|
||||
}
|
||||
184
agent/brain.go
184
agent/brain.go
@@ -1,184 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"nofx/safe"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Brain handles proactive intelligence: signals, news, market briefs.
|
||||
type Brain struct {
|
||||
agent *Agent
|
||||
logger *slog.Logger
|
||||
http *http.Client
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
recentSignals sync.Map // debounce
|
||||
}
|
||||
|
||||
func NewBrain(agent *Agent, logger *slog.Logger) *Brain {
|
||||
return &Brain{
|
||||
agent: agent,
|
||||
logger: logger,
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Brain) Stop() { b.stopOnce.Do(func() { close(b.stopCh) }) }
|
||||
|
||||
// cleanStaleSignals removes debounce entries older than 30 minutes.
|
||||
func (b *Brain) cleanStaleSignals() {
|
||||
cutoff := time.Now().Add(-30 * time.Minute)
|
||||
b.recentSignals.Range(func(key, value any) bool {
|
||||
if t, ok := value.(time.Time); ok && t.Before(cutoff) {
|
||||
b.recentSignals.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Brain) HandleSignal(sig Signal) {
|
||||
key := fmt.Sprintf("%s:%s", sig.Type, sig.Symbol)
|
||||
if v, ok := b.recentSignals.Load(key); ok {
|
||||
if time.Since(v.(time.Time)) < 10*time.Minute {
|
||||
return
|
||||
}
|
||||
}
|
||||
b.recentSignals.Store(key, time.Now())
|
||||
|
||||
emoji := map[string]string{"info": "ℹ️", "warning": "⚠️", "critical": "🚨"}
|
||||
e := emoji[sig.Severity]
|
||||
if e == "" { e = "📊" }
|
||||
|
||||
b.agent.notifyAll(fmt.Sprintf("%s *%s*\n\n%s", e, sig.Title, sig.Detail))
|
||||
}
|
||||
|
||||
func (b *Brain) StartNewsScan(interval time.Duration) {
|
||||
seen := make(map[string]bool)
|
||||
safe.GoNamed("brain-news-scan", func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
cleanTick := 0
|
||||
for {
|
||||
select {
|
||||
case <-b.stopCh: return
|
||||
case <-ticker.C:
|
||||
b.scanNews(seen)
|
||||
cleanTick++
|
||||
if cleanTick%6 == 0 { // every ~30 min
|
||||
b.cleanStaleSignals()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Brain) scanNews(seen map[string]bool) {
|
||||
resp, err := b.http.Get("https://min-api.cryptocompare.com/data/v2/news/?lang=EN&sortOrder=latest")
|
||||
if err != nil { return }
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b.logger.Debug("news API non-200", "status", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
body, err := safe.ReadAllLimited(resp.Body, 1024*1024) // 1MB limit
|
||||
if err != nil { return }
|
||||
|
||||
var result struct {
|
||||
Data []struct {
|
||||
Title string `json:"title"`
|
||||
Source string `json:"source"`
|
||||
URL string `json:"url"`
|
||||
Body string `json:"body"`
|
||||
Categories string `json:"categories"`
|
||||
PublishedOn int64 `json:"published_on"`
|
||||
} `json:"Data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil { return }
|
||||
|
||||
bullish := []string{"surge", "rally", "bullish", "breakout", "ath", "pump", "adoption"}
|
||||
bearish := []string{"crash", "dump", "bearish", "sell-off", "plunge", "hack", "ban", "fraud"}
|
||||
|
||||
for _, d := range result.Data {
|
||||
if seen[d.URL] { continue }
|
||||
seen[d.URL] = true
|
||||
if time.Since(time.Unix(d.PublishedOn, 0)) > 10*time.Minute { continue }
|
||||
|
||||
lower := strings.ToLower(d.Title + " " + d.Body)
|
||||
bc, brc := 0, 0
|
||||
for _, w := range bullish { if strings.Contains(lower, w) { bc++ } }
|
||||
for _, w := range bearish { if strings.Contains(lower, w) { brc++ } }
|
||||
|
||||
if bc == 0 && brc == 0 { continue }
|
||||
|
||||
emoji := "📰"
|
||||
sentiment := "NEUTRAL"
|
||||
if bc > brc { emoji = "🟢"; sentiment = "BULLISH" }
|
||||
if brc > bc { emoji = "🔴"; sentiment = "BEARISH" }
|
||||
|
||||
b.agent.notifyAll(fmt.Sprintf("%s *News*\n\n%s\n\n• Source: %s\n• Sentiment: %s",
|
||||
emoji, d.Title, d.Source, sentiment))
|
||||
}
|
||||
|
||||
// Evict ~half when seen map gets large (keep recent half to avoid re-notifying)
|
||||
if len(seen) > 1000 {
|
||||
i, half := 0, len(seen)/2
|
||||
for k := range seen {
|
||||
if i >= half { break }
|
||||
delete(seen, k)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Brain) StartMarketBriefs(hours []int) {
|
||||
safe.GoNamed("brain-market-briefs", func() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
sent := make(map[string]bool)
|
||||
for {
|
||||
select {
|
||||
case <-b.stopCh: return
|
||||
case now := <-ticker.C:
|
||||
key := now.Format("2006-01-02-15")
|
||||
for _, h := range hours {
|
||||
if now.Hour() == h && now.Minute() == 30 && !sent[key] {
|
||||
sent[key] = true
|
||||
b.sendBrief(h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Brain) sendBrief(hour int) {
|
||||
title := "☀️ *早间市场简报*"
|
||||
if hour >= 18 { title = "🌙 *晚间市场简报*" }
|
||||
|
||||
// Fetch BTC/ETH prices for the brief
|
||||
var btcPrice, ethPrice, btcChg, ethChg string
|
||||
for _, sym := range []string{"BTCUSDT", "ETHUSDT"} {
|
||||
resp, err := b.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", sym))
|
||||
if err != nil { continue }
|
||||
body, readErr := safe.ReadAllLimited(resp.Body, 64*1024) // 64KB limit
|
||||
statusOK := resp.StatusCode == http.StatusOK
|
||||
resp.Body.Close()
|
||||
if readErr != nil || !statusOK { continue }
|
||||
var t map[string]string
|
||||
if err := json.Unmarshal(body, &t); err != nil { continue }
|
||||
if sym == "BTCUSDT" { btcPrice = t["lastPrice"]; btcChg = t["priceChangePercent"] }
|
||||
if sym == "ETHUSDT" { ethPrice = t["lastPrice"]; ethChg = t["priceChangePercent"] }
|
||||
}
|
||||
|
||||
brief := fmt.Sprintf("%s\n\n• BTC: $%s (%s%%)\n• ETH: $%s (%s%%)\n\n_%s_",
|
||||
title, btcPrice, btcChg, ethPrice, ethChg, time.Now().Format("2006-01-02 15:04"))
|
||||
|
||||
b.agent.notifyAll(brief)
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
func newTestAgentWithStore(t *testing.T) *Agent {
|
||||
t.Helper()
|
||||
st, err := store.New(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("create test store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = st.Close()
|
||||
})
|
||||
return &Agent{store: st}
|
||||
}
|
||||
|
||||
func TestToolManageExchangeConfigLifecycle(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
createResp := a.toolManageExchangeConfig("user-1", `{
|
||||
"action":"create",
|
||||
"exchange_type":"binance",
|
||||
"account_name":"Main",
|
||||
"enabled":true,
|
||||
"testnet":true
|
||||
}`)
|
||||
|
||||
var created struct {
|
||||
Status string `json:"status"`
|
||||
Action string `json:"action"`
|
||||
Exchange safeExchangeToolConfig `json:"exchange"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
|
||||
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
|
||||
}
|
||||
if created.Status != "ok" || created.Action != "create" {
|
||||
t.Fatalf("unexpected create response: %+v", created)
|
||||
}
|
||||
if created.Exchange.AccountName != "Main" || created.Exchange.ExchangeType != "binance" {
|
||||
t.Fatalf("unexpected exchange payload: %+v", created.Exchange)
|
||||
}
|
||||
|
||||
updateResp := a.toolManageExchangeConfig("user-1", `{
|
||||
"action":"update",
|
||||
"exchange_id":"`+created.Exchange.ID+`",
|
||||
"account_name":"Renamed",
|
||||
"enabled":false
|
||||
}`)
|
||||
var updated struct {
|
||||
Status string `json:"status"`
|
||||
Action string `json:"action"`
|
||||
Exchange safeExchangeToolConfig `json:"exchange"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
|
||||
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
|
||||
}
|
||||
if updated.Exchange.AccountName != "Renamed" || updated.Exchange.Enabled {
|
||||
t.Fatalf("unexpected updated exchange payload: %+v", updated.Exchange)
|
||||
}
|
||||
|
||||
deleteResp := a.toolManageExchangeConfig("user-1", `{
|
||||
"action":"delete",
|
||||
"exchange_id":"`+created.Exchange.ID+`"
|
||||
}`)
|
||||
var deleted map[string]any
|
||||
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
|
||||
t.Fatalf("unmarshal delete response: %v\nraw=%s", err, deleteResp)
|
||||
}
|
||||
if deleted["status"] != "ok" || deleted["action"] != "delete" {
|
||||
t.Fatalf("unexpected delete response: %+v", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolManageModelConfigLifecycle(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
createResp := a.toolManageModelConfig("user-1", `{
|
||||
"action":"create",
|
||||
"provider":"openai",
|
||||
"enabled":true,
|
||||
"api_key":"sk-test",
|
||||
"custom_api_url":"https://api.openai.com/v1",
|
||||
"custom_model_name":"gpt-5-mini"
|
||||
}`)
|
||||
|
||||
var created struct {
|
||||
Status string `json:"status"`
|
||||
Action string `json:"action"`
|
||||
Model safeModelToolConfig `json:"model"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
|
||||
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
|
||||
}
|
||||
if created.Status != "ok" || created.Action != "create" {
|
||||
t.Fatalf("unexpected create response: %+v", created)
|
||||
}
|
||||
if created.Model.Provider != "openai" || created.Model.CustomModelName != "gpt-5-mini" {
|
||||
t.Fatalf("unexpected model payload: %+v", created.Model)
|
||||
}
|
||||
|
||||
updateResp := a.toolManageModelConfig("user-1", `{
|
||||
"action":"update",
|
||||
"model_id":"`+created.Model.ID+`",
|
||||
"enabled":false,
|
||||
"custom_model_name":"gpt-5"
|
||||
}`)
|
||||
var updated struct {
|
||||
Status string `json:"status"`
|
||||
Action string `json:"action"`
|
||||
Model safeModelToolConfig `json:"model"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
|
||||
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
|
||||
}
|
||||
if updated.Model.Enabled || updated.Model.CustomModelName != "gpt-5" {
|
||||
t.Fatalf("unexpected updated model payload: %+v", updated.Model)
|
||||
}
|
||||
|
||||
deleteResp := a.toolManageModelConfig("user-1", `{
|
||||
"action":"delete",
|
||||
"model_id":"`+created.Model.ID+`"
|
||||
}`)
|
||||
var deleted map[string]any
|
||||
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
|
||||
t.Fatalf("unmarshal delete response: %v\nraw=%s", err, deleteResp)
|
||||
}
|
||||
if deleted["status"] != "ok" || deleted["action"] != "delete" {
|
||||
t.Fatalf("unexpected delete response: %+v", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolManageModelConfigRejectsEnableWithoutAPIKey(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
createResp := a.toolManageModelConfig("user-1", `{
|
||||
"action":"create",
|
||||
"provider":"openai",
|
||||
"enabled":false,
|
||||
"custom_model_name":"gpt-4o"
|
||||
}`)
|
||||
var created struct {
|
||||
Model safeModelToolConfig `json:"model"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
|
||||
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
|
||||
}
|
||||
|
||||
updateResp := a.toolManageModelConfig("user-1", `{
|
||||
"action":"update",
|
||||
"model_id":"`+created.Model.ID+`",
|
||||
"enabled":true
|
||||
}`)
|
||||
if !strings.Contains(updateResp, "cannot enable model config before API key is configured") {
|
||||
t.Fatalf("expected enabling incomplete model to fail, got %s", updateResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDefaultSkipsEnabledModelWithoutAPIKey(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
incompleteCreate := a.toolManageModelConfig("user-1", `{
|
||||
"action":"create",
|
||||
"provider":"openai",
|
||||
"enabled":true,
|
||||
"custom_model_name":"gpt-4o"
|
||||
}`)
|
||||
var incomplete struct {
|
||||
Model safeModelToolConfig `json:"model"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(incompleteCreate), &incomplete); err != nil {
|
||||
t.Fatalf("unmarshal incomplete create response: %v\nraw=%s", err, incompleteCreate)
|
||||
}
|
||||
|
||||
completeCreate := a.toolManageModelConfig("user-1", `{
|
||||
"action":"create",
|
||||
"provider":"deepseek",
|
||||
"enabled":true,
|
||||
"api_key":"sk-test",
|
||||
"custom_model_name":"deepseek-chat"
|
||||
}`)
|
||||
var complete struct {
|
||||
Model safeModelToolConfig `json:"model"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(completeCreate), &complete); err != nil {
|
||||
t.Fatalf("unmarshal complete create response: %v\nraw=%s", err, completeCreate)
|
||||
}
|
||||
|
||||
model, err := a.store.AIModel().GetDefault("user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDefault() error = %v", err)
|
||||
}
|
||||
if model.ID != complete.Model.ID {
|
||||
t.Fatalf("expected GetDefault to skip incomplete enabled model and return %s, got %s", complete.Model.ID, model.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolManageTraderLifecycle(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
modelResp := a.toolManageModelConfig("user-1", `{
|
||||
"action":"create",
|
||||
"provider":"openai",
|
||||
"enabled":true,
|
||||
"api_key":"sk-test",
|
||||
"custom_api_url":"https://api.openai.com/v1",
|
||||
"custom_model_name":"gpt-5-mini"
|
||||
}`)
|
||||
var modelCreated struct {
|
||||
Model safeModelToolConfig `json:"model"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil {
|
||||
t.Fatalf("unmarshal model response: %v", err)
|
||||
}
|
||||
|
||||
exchangeResp := a.toolManageExchangeConfig("user-1", `{
|
||||
"action":"create",
|
||||
"exchange_type":"binance",
|
||||
"account_name":"Main",
|
||||
"enabled":true
|
||||
}`)
|
||||
var exchangeCreated struct {
|
||||
Exchange safeExchangeToolConfig `json:"exchange"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil {
|
||||
t.Fatalf("unmarshal exchange response: %v", err)
|
||||
}
|
||||
|
||||
createResp := a.toolManageTrader("user-1", `{
|
||||
"action":"create",
|
||||
"name":"Momentum Trader",
|
||||
"ai_model_id":"`+modelCreated.Model.ID+`",
|
||||
"exchange_id":"`+exchangeCreated.Exchange.ID+`",
|
||||
"scan_interval_minutes":5
|
||||
}`)
|
||||
var created struct {
|
||||
Status string `json:"status"`
|
||||
Action string `json:"action"`
|
||||
Trader safeTraderToolConfig `json:"trader"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
|
||||
t.Fatalf("unmarshal create trader response: %v\nraw=%s", err, createResp)
|
||||
}
|
||||
if created.Status != "ok" || created.Action != "create" {
|
||||
t.Fatalf("unexpected create trader response: %+v", created)
|
||||
}
|
||||
if created.Trader.Name != "Momentum Trader" || created.Trader.ScanIntervalMinutes != 5 {
|
||||
t.Fatalf("unexpected created trader: %+v", created.Trader)
|
||||
}
|
||||
|
||||
listResp := a.toolManageTrader("user-1", `{"action":"list"}`)
|
||||
var listed struct {
|
||||
Count int `json:"count"`
|
||||
Traders []safeTraderToolConfig `json:"traders"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(listResp), &listed); err != nil {
|
||||
t.Fatalf("unmarshal list response: %v\nraw=%s", err, listResp)
|
||||
}
|
||||
if listed.Count != 1 || len(listed.Traders) != 1 {
|
||||
t.Fatalf("unexpected trader list: %+v", listed)
|
||||
}
|
||||
|
||||
updateResp := a.toolManageTrader("user-1", `{
|
||||
"action":"update",
|
||||
"trader_id":"`+created.Trader.ID+`",
|
||||
"name":"Renamed Trader",
|
||||
"scan_interval_minutes":8
|
||||
}`)
|
||||
var updated struct {
|
||||
Status string `json:"status"`
|
||||
Action string `json:"action"`
|
||||
Trader safeTraderToolConfig `json:"trader"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
|
||||
t.Fatalf("unmarshal update trader response: %v\nraw=%s", err, updateResp)
|
||||
}
|
||||
if updated.Trader.Name != "Renamed Trader" || updated.Trader.ScanIntervalMinutes != 8 {
|
||||
t.Fatalf("unexpected updated trader: %+v", updated.Trader)
|
||||
}
|
||||
|
||||
deleteResp := a.toolManageTrader("user-1", `{
|
||||
"action":"delete",
|
||||
"trader_id":"`+created.Trader.ID+`"
|
||||
}`)
|
||||
var deleted map[string]any
|
||||
if err := json.Unmarshal([]byte(deleteResp), &deleted); err != nil {
|
||||
t.Fatalf("unmarshal delete trader response: %v\nraw=%s", err, deleteResp)
|
||||
}
|
||||
if deleted["status"] != "ok" || deleted["action"] != "delete" {
|
||||
t.Fatalf("unexpected delete trader response: %+v", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolManageStrategyLifecycle(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
createResp := a.toolManageStrategy("user-1", `{
|
||||
"action":"create",
|
||||
"name":"激进",
|
||||
"description":"激进策略模板",
|
||||
"lang":"zh"
|
||||
}`)
|
||||
|
||||
var created struct {
|
||||
Status string `json:"status"`
|
||||
Action string `json:"action"`
|
||||
Strategy safeStrategyToolConfig `json:"strategy"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
|
||||
t.Fatalf("unmarshal create response: %v\nraw=%s", err, createResp)
|
||||
}
|
||||
if created.Status != "ok" || created.Action != "create" {
|
||||
t.Fatalf("unexpected create response: %+v", created)
|
||||
}
|
||||
if created.Strategy.Name != "激进" {
|
||||
t.Fatalf("unexpected strategy payload: %+v", created.Strategy)
|
||||
}
|
||||
|
||||
listResp := a.toolGetStrategies("user-1")
|
||||
if !strings.Contains(listResp, "激进") {
|
||||
t.Fatalf("expected created strategy in list, got %s", listResp)
|
||||
}
|
||||
|
||||
updateResp := a.toolManageStrategy("user-1", `{
|
||||
"action":"update",
|
||||
"strategy_id":"`+created.Strategy.ID+`",
|
||||
"description":"更新后的描述"
|
||||
}`)
|
||||
var updated struct {
|
||||
Status string `json:"status"`
|
||||
Action string `json:"action"`
|
||||
Strategy safeStrategyToolConfig `json:"strategy"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(updateResp), &updated); err != nil {
|
||||
t.Fatalf("unmarshal update response: %v\nraw=%s", err, updateResp)
|
||||
}
|
||||
if updated.Strategy.Description != "更新后的描述" {
|
||||
t.Fatalf("unexpected updated strategy payload: %+v", updated.Strategy)
|
||||
}
|
||||
|
||||
activateResp := a.toolManageStrategy("user-1", `{
|
||||
"action":"activate",
|
||||
"strategy_id":"`+created.Strategy.ID+`"
|
||||
}`)
|
||||
if !strings.Contains(activateResp, `"action":"activate"`) {
|
||||
t.Fatalf("unexpected activate response: %s", activateResp)
|
||||
}
|
||||
|
||||
deleteResp := a.toolManageStrategy("user-1", `{
|
||||
"action":"delete",
|
||||
"strategy_id":"`+created.Strategy.ID+`"
|
||||
}`)
|
||||
if !strings.Contains(deleteResp, `"action":"delete"`) {
|
||||
t.Fatalf("unexpected delete response: %s", deleteResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAIClientFromStoreUserUsesUserSpecificEnabledModel(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
if err := a.store.AIModel().Update("user-42", "openai", true, "sk-test", "https://api.openai.com/v1", "gpt-5-mini"); err != nil {
|
||||
t.Fatalf("seed model: %v", err)
|
||||
}
|
||||
|
||||
client, modelName, ok := a.loadAIClientFromStoreUser("user-42")
|
||||
if !ok {
|
||||
t.Fatal("expected AI client to load from user-specific model")
|
||||
}
|
||||
if client == nil {
|
||||
t.Fatal("expected non-nil AI client")
|
||||
}
|
||||
if modelName != "gpt-5-mini" {
|
||||
t.Fatalf("unexpected model name: %s", modelName)
|
||||
}
|
||||
|
||||
// After the provider registry refactor, registered providers (like openai)
|
||||
// return their own AIClient implementation, not *mcp.Client.
|
||||
if client == nil {
|
||||
t.Fatal("expected non-nil AI client from provider registry")
|
||||
}
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
executionStatusPlanning = "planning"
|
||||
executionStatusRunning = "running"
|
||||
executionStatusWaitingUser = "waiting_user"
|
||||
executionStatusCompleted = "completed"
|
||||
executionStatusFailed = "failed"
|
||||
)
|
||||
|
||||
const (
|
||||
planStepTypeTool = "tool"
|
||||
planStepTypeReason = "reason"
|
||||
planStepTypeAskUser = "ask_user"
|
||||
planStepTypeRespond = "respond"
|
||||
)
|
||||
|
||||
const (
|
||||
planStepStatusPending = "pending"
|
||||
planStepStatusRunning = "running"
|
||||
planStepStatusCompleted = "completed"
|
||||
planStepStatusFailed = "failed"
|
||||
)
|
||||
|
||||
type ExecutionState struct {
|
||||
SessionID string `json:"session_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Goal string `json:"goal"`
|
||||
Status string `json:"status"`
|
||||
PlanID string `json:"plan_id"`
|
||||
Steps []PlanStep `json:"steps,omitempty"`
|
||||
CurrentStepID string `json:"current_step_id,omitempty"`
|
||||
CurrentReferences *CurrentReferences `json:"current_references,omitempty"`
|
||||
DynamicSnapshots []Observation `json:"dynamic_snapshots,omitempty"`
|
||||
ExecutionLog []Observation `json:"execution_log,omitempty"`
|
||||
SummaryNotes []Observation `json:"summary_notes,omitempty"`
|
||||
Waiting *WaitingState `json:"waiting,omitempty"`
|
||||
Observations []Observation `json:"observations,omitempty"`
|
||||
FinalAnswer string `json:"final_answer,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PlanStep struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
ToolArgs map[string]any `json:"tool_args,omitempty"`
|
||||
Instruction string `json:"instruction,omitempty"`
|
||||
RequiresConfirmation bool `json:"requires_confirmation,omitempty"`
|
||||
OutputSummary string `json:"output_summary,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Observation struct {
|
||||
StepID string `json:"step_id,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Summary string `json:"summary"`
|
||||
RawJSON string `json:"raw_json,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type WaitingState struct {
|
||||
Question string `json:"question,omitempty"`
|
||||
Intent string `json:"intent,omitempty"`
|
||||
PendingFields []string `json:"pending_fields,omitempty"`
|
||||
ConfirmationTarget string `json:"confirmation_target,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
type EntityReference struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type CurrentReferences struct {
|
||||
Strategy *EntityReference `json:"strategy,omitempty"`
|
||||
Trader *EntityReference `json:"trader,omitempty"`
|
||||
Model *EntityReference `json:"model,omitempty"`
|
||||
Exchange *EntityReference `json:"exchange,omitempty"`
|
||||
}
|
||||
|
||||
type executionPlan struct {
|
||||
Goal string `json:"goal"`
|
||||
Steps []PlanStep `json:"steps"`
|
||||
}
|
||||
|
||||
const (
|
||||
executionLogMaxEntries = 8
|
||||
summaryNotesMaxEntries = 4
|
||||
)
|
||||
|
||||
func ExecutionStateConfigKey(userID int64) string {
|
||||
return fmt.Sprintf("agent_execution_state_%d", userID)
|
||||
}
|
||||
|
||||
func (a *Agent) getExecutionState(userID int64) ExecutionState {
|
||||
if a.store == nil {
|
||||
return ExecutionState{}
|
||||
}
|
||||
raw, err := a.store.GetSystemConfig(ExecutionStateConfigKey(userID))
|
||||
if err != nil {
|
||||
a.logger.Warn("failed to load execution state", "error", err, "user_id", userID)
|
||||
return ExecutionState{}
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ExecutionState{}
|
||||
}
|
||||
|
||||
var state ExecutionState
|
||||
if err := json.Unmarshal([]byte(raw), &state); err != nil {
|
||||
a.logger.Warn("failed to parse execution state", "error", err, "user_id", userID)
|
||||
return ExecutionState{}
|
||||
}
|
||||
return normalizeExecutionState(state)
|
||||
}
|
||||
|
||||
func (a *Agent) saveExecutionState(state ExecutionState) error {
|
||||
if a.store == nil {
|
||||
return fmt.Errorf("store unavailable")
|
||||
}
|
||||
state = normalizeExecutionState(state)
|
||||
if state.SessionID == "" {
|
||||
return a.store.SetSystemConfig(ExecutionStateConfigKey(state.UserID), "")
|
||||
}
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.store.SetSystemConfig(ExecutionStateConfigKey(state.UserID), string(data))
|
||||
}
|
||||
|
||||
func (a *Agent) clearExecutionState(userID int64) {
|
||||
if a.store == nil {
|
||||
return
|
||||
}
|
||||
if err := a.store.SetSystemConfig(ExecutionStateConfigKey(userID), ""); err != nil {
|
||||
a.logger.Warn("failed to clear execution state", "error", err, "user_id", userID)
|
||||
}
|
||||
}
|
||||
|
||||
func newExecutionState(userID int64, goal string) ExecutionState {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
return normalizeExecutionState(ExecutionState{
|
||||
SessionID: fmt.Sprintf("sess_%d", time.Now().UTC().UnixNano()),
|
||||
UserID: userID,
|
||||
Goal: strings.TrimSpace(goal),
|
||||
Status: executionStatusPlanning,
|
||||
PlanID: fmt.Sprintf("plan_%d", time.Now().UTC().UnixNano()),
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeExecutionState(state ExecutionState) ExecutionState {
|
||||
state.Goal = strings.TrimSpace(state.Goal)
|
||||
state.Status = strings.TrimSpace(state.Status)
|
||||
state.CurrentStepID = strings.TrimSpace(state.CurrentStepID)
|
||||
state.FinalAnswer = strings.TrimSpace(state.FinalAnswer)
|
||||
state.LastError = strings.TrimSpace(state.LastError)
|
||||
state.CurrentReferences = normalizeCurrentReferences(state.CurrentReferences)
|
||||
state.Waiting = normalizeWaitingState(state.Waiting)
|
||||
if state.Status == "" && state.SessionID != "" {
|
||||
state.Status = executionStatusPlanning
|
||||
}
|
||||
for i := range state.Steps {
|
||||
state.Steps[i].ID = strings.TrimSpace(state.Steps[i].ID)
|
||||
if state.Steps[i].ID == "" {
|
||||
state.Steps[i].ID = fmt.Sprintf("step_%d", i+1)
|
||||
}
|
||||
state.Steps[i].Type = strings.TrimSpace(state.Steps[i].Type)
|
||||
state.Steps[i].Title = strings.TrimSpace(state.Steps[i].Title)
|
||||
state.Steps[i].ToolName = strings.TrimSpace(state.Steps[i].ToolName)
|
||||
state.Steps[i].Instruction = strings.TrimSpace(state.Steps[i].Instruction)
|
||||
state.Steps[i].OutputSummary = strings.TrimSpace(state.Steps[i].OutputSummary)
|
||||
state.Steps[i].Error = strings.TrimSpace(state.Steps[i].Error)
|
||||
if state.Steps[i].Status == "" {
|
||||
state.Steps[i].Status = planStepStatusPending
|
||||
}
|
||||
}
|
||||
if len(state.Observations) > 0 {
|
||||
state.ExecutionLog = append(state.ExecutionLog, state.Observations...)
|
||||
state.Observations = nil
|
||||
}
|
||||
state.DynamicSnapshots = normalizeObservationList(state.DynamicSnapshots)
|
||||
state.ExecutionLog = normalizeObservationList(state.ExecutionLog)
|
||||
state.SummaryNotes = normalizeObservationList(state.SummaryNotes)
|
||||
state = compactExecutionLog(state)
|
||||
if state.UpdatedAt == "" && state.SessionID != "" {
|
||||
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func normalizeWaitingState(waiting *WaitingState) *WaitingState {
|
||||
if waiting == nil {
|
||||
return nil
|
||||
}
|
||||
waiting.Question = strings.TrimSpace(waiting.Question)
|
||||
waiting.Intent = strings.TrimSpace(waiting.Intent)
|
||||
waiting.PendingFields = cleanStringList(waiting.PendingFields)
|
||||
waiting.ConfirmationTarget = strings.TrimSpace(waiting.ConfirmationTarget)
|
||||
if waiting.CreatedAt == "" && (waiting.Question != "" || waiting.Intent != "" || len(waiting.PendingFields) > 0 || waiting.ConfirmationTarget != "") {
|
||||
waiting.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
if waiting.Question == "" && waiting.Intent == "" && len(waiting.PendingFields) == 0 && waiting.ConfirmationTarget == "" {
|
||||
return nil
|
||||
}
|
||||
return waiting
|
||||
}
|
||||
|
||||
func normalizeEntityReference(ref *EntityReference) *EntityReference {
|
||||
if ref == nil {
|
||||
return nil
|
||||
}
|
||||
ref.ID = strings.TrimSpace(ref.ID)
|
||||
ref.Name = strings.TrimSpace(ref.Name)
|
||||
if ref.ID == "" && ref.Name == "" {
|
||||
return nil
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
func normalizeCurrentReferences(refs *CurrentReferences) *CurrentReferences {
|
||||
if refs == nil {
|
||||
return nil
|
||||
}
|
||||
refs.Strategy = normalizeEntityReference(refs.Strategy)
|
||||
refs.Trader = normalizeEntityReference(refs.Trader)
|
||||
refs.Model = normalizeEntityReference(refs.Model)
|
||||
refs.Exchange = normalizeEntityReference(refs.Exchange)
|
||||
if refs.Strategy == nil && refs.Trader == nil && refs.Model == nil && refs.Exchange == nil {
|
||||
return nil
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func normalizeObservationList(values []Observation) []Observation {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Observation, 0, len(values))
|
||||
for _, value := range values {
|
||||
value.StepID = strings.TrimSpace(value.StepID)
|
||||
value.Kind = strings.TrimSpace(value.Kind)
|
||||
value.Summary = strings.TrimSpace(value.Summary)
|
||||
value.RawJSON = strings.TrimSpace(value.RawJSON)
|
||||
if value.Kind == "" && value.Summary == "" && value.RawJSON == "" {
|
||||
continue
|
||||
}
|
||||
if value.CreatedAt == "" {
|
||||
value.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func compactExecutionLog(state ExecutionState) ExecutionState {
|
||||
if len(state.ExecutionLog) <= executionLogMaxEntries {
|
||||
if len(state.SummaryNotes) > summaryNotesMaxEntries {
|
||||
state.SummaryNotes = state.SummaryNotes[len(state.SummaryNotes)-summaryNotesMaxEntries:]
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
overflow := state.ExecutionLog[:len(state.ExecutionLog)-executionLogMaxEntries]
|
||||
state.ExecutionLog = state.ExecutionLog[len(state.ExecutionLog)-executionLogMaxEntries:]
|
||||
summary := summarizeExecutionOverflow(overflow)
|
||||
if summary != nil {
|
||||
state.SummaryNotes = append(state.SummaryNotes, *summary)
|
||||
if len(state.SummaryNotes) > summaryNotesMaxEntries {
|
||||
state.SummaryNotes = state.SummaryNotes[len(state.SummaryNotes)-summaryNotesMaxEntries:]
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func summarizeExecutionOverflow(values []Observation) *Observation {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
summaries := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
label := value.Kind
|
||||
if label == "" {
|
||||
label = "observation"
|
||||
}
|
||||
if value.Summary != "" {
|
||||
summaries = append(summaries, fmt.Sprintf("%s: %s", label, value.Summary))
|
||||
} else if value.RawJSON != "" {
|
||||
summaries = append(summaries, fmt.Sprintf("%s: %s", label, value.RawJSON))
|
||||
}
|
||||
}
|
||||
if len(summaries) == 0 {
|
||||
return nil
|
||||
}
|
||||
text := strings.Join(summaries, " | ")
|
||||
if len(text) > 500 {
|
||||
text = text[:500] + "..."
|
||||
}
|
||||
return &Observation{
|
||||
Kind: "execution_summary",
|
||||
Summary: text,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func appendDynamicSnapshot(state *ExecutionState, obs Observation) {
|
||||
state.DynamicSnapshots = append(state.DynamicSnapshots, obs)
|
||||
state.DynamicSnapshots = normalizeObservationList(state.DynamicSnapshots)
|
||||
}
|
||||
|
||||
func appendExecutionLog(state *ExecutionState, obs Observation) {
|
||||
state.ExecutionLog = append(state.ExecutionLog, obs)
|
||||
*state = normalizeExecutionState(*state)
|
||||
}
|
||||
|
||||
func buildObservationContext(state ExecutionState) map[string]any {
|
||||
state = normalizeExecutionState(state)
|
||||
return map[string]any{
|
||||
"current_references": state.CurrentReferences,
|
||||
"dynamic_snapshots": state.DynamicSnapshots,
|
||||
"execution_log": state.ExecutionLog,
|
||||
"summary_notes": state.SummaryNotes,
|
||||
}
|
||||
}
|
||||
103
agent/history.go
103
agent/history.go
@@ -1,103 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// chatMessage represents a single message in conversation history.
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"` // "user" or "assistant"
|
||||
Content string `json:"content"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// chatHistory stores conversation history per user.
|
||||
type chatHistory struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[int64][]chatMessage
|
||||
maxTurns int // hard safety cap in messages per user
|
||||
}
|
||||
|
||||
func newChatHistory(maxTurns int) *chatHistory {
|
||||
if maxTurns <= 0 {
|
||||
maxTurns = 100 // default hard cap; recent-window trimming is handled separately
|
||||
}
|
||||
return &chatHistory{
|
||||
sessions: make(map[int64][]chatMessage),
|
||||
maxTurns: maxTurns,
|
||||
}
|
||||
}
|
||||
|
||||
// Add appends a message to the user's history.
|
||||
func (h *chatHistory) Add(userID int64, role, content string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
h.sessions[userID] = append(h.sessions[userID], chatMessage{
|
||||
Role: role,
|
||||
Content: content,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
|
||||
// Hard safety cap in case summarization is unavailable.
|
||||
msgs := h.sessions[userID]
|
||||
if len(msgs) > h.maxTurns {
|
||||
h.sessions[userID] = msgs[len(msgs)-h.maxTurns:]
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the conversation history for a user.
|
||||
func (h *chatHistory) Get(userID int64) []chatMessage {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
msgs := h.sessions[userID]
|
||||
if msgs == nil {
|
||||
return nil
|
||||
}
|
||||
// Return a copy
|
||||
result := make([]chatMessage, len(msgs))
|
||||
copy(result, msgs)
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *chatHistory) Replace(userID int64, msgs []chatMessage) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if len(msgs) == 0 {
|
||||
delete(h.sessions, userID)
|
||||
return
|
||||
}
|
||||
|
||||
if len(msgs) > h.maxTurns {
|
||||
msgs = msgs[len(msgs)-h.maxTurns:]
|
||||
}
|
||||
cloned := make([]chatMessage, len(msgs))
|
||||
copy(cloned, msgs)
|
||||
h.sessions[userID] = cloned
|
||||
}
|
||||
|
||||
// Clear resets conversation history for a user.
|
||||
func (h *chatHistory) Clear(userID int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.sessions, userID)
|
||||
}
|
||||
|
||||
// CleanOld removes sessions older than the given duration.
|
||||
func (h *chatHistory) CleanOld(maxAge time.Duration) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for uid, msgs := range h.sessions {
|
||||
if len(msgs) > 0 {
|
||||
lastMsg := msgs[len(msgs)-1]
|
||||
if now.Sub(lastMsg.Timestamp) > maxAge {
|
||||
delete(h.sessions, uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package agent
|
||||
|
||||
var i18nMessages = map[string]map[string]string{
|
||||
"help": {
|
||||
"zh": "🤖 *NOFXi — 你的 AI 交易 Agent*\n\n" +
|
||||
"*交易:* /buy /sell /long /short + 交易对 数量 杠杆\n" +
|
||||
"*查询:* /positions /balance /pnl /traders\n" +
|
||||
"*分析:* /analyze BTC\n" +
|
||||
"*监控:* /watch BTC · /unwatch BTC\n" +
|
||||
"*策略:* /strategy\n" +
|
||||
"*系统:* /status /help\n\n" +
|
||||
"直接跟我说话就行,中英文都可以 💬",
|
||||
"en": "🤖 *NOFXi — Your AI Trading Agent*\n\n" +
|
||||
"*Trade:* /buy /sell /long /short + symbol qty leverage\n" +
|
||||
"*Query:* /positions /balance /pnl /traders\n" +
|
||||
"*Analyze:* /analyze BTC\n" +
|
||||
"*Monitor:* /watch BTC · /unwatch BTC\n" +
|
||||
"*Strategy:* /strategy\n" +
|
||||
"*System:* /status /help\n\n" +
|
||||
"Just talk to me in any language 💬",
|
||||
},
|
||||
"status": {
|
||||
"zh": "📊 *NOFXi 状态*\n\n• Traders: %d/%d 运行中\n• 监控: %d 个交易对\n• AI: %s\n• 时间: %s",
|
||||
"en": "📊 *NOFXi Status*\n\n• Traders: %d/%d running\n• Watching: %d symbols\n• AI: %s\n• Time: %s",
|
||||
},
|
||||
"no_traders": {
|
||||
"zh": "📭 暂无 Trader。请在 Web UI 中创建和配置。",
|
||||
"en": "📭 No traders configured. Create one in Web UI.",
|
||||
},
|
||||
"no_running_trader": {
|
||||
"zh": "⚠️ 没有运行中的 Trader。请在 Web UI 中启动。",
|
||||
"en": "⚠️ No running trader. Start one in Web UI.",
|
||||
},
|
||||
"no_positions": {
|
||||
"zh": "📭 当前没有持仓。",
|
||||
"en": "📭 No open positions.",
|
||||
},
|
||||
"positions_header": {
|
||||
"zh": "📊 *当前持仓*\n\n",
|
||||
"en": "📊 *Open Positions*\n\n",
|
||||
},
|
||||
"total_pnl": {
|
||||
"zh": "💰 *总未实现盈亏: $%.2f*",
|
||||
"en": "💰 *Total Unrealized P/L: $%.2f*",
|
||||
},
|
||||
"balance_header": {
|
||||
"zh": "💰 *账户余额*\n\n",
|
||||
"en": "💰 *Account Balances*\n\n",
|
||||
},
|
||||
"traders_header": {
|
||||
"zh": "🤖 *Traders*\n\n",
|
||||
"en": "🤖 *Traders*\n\n",
|
||||
},
|
||||
"trade_usage": {
|
||||
"zh": "用法: `/buy BTC 0.01` 或 `/sell ETH 0.5 3x`",
|
||||
"en": "Usage: `/buy BTC 0.01` or `/sell ETH 0.5 3x`",
|
||||
},
|
||||
"invalid_qty": {
|
||||
"zh": "❓ 无效数量: %s",
|
||||
"en": "❓ Invalid quantity: %s",
|
||||
},
|
||||
"analysis_header": {
|
||||
"zh": "🔍 *%s 市场分析*",
|
||||
"en": "🔍 *%s Analysis*",
|
||||
},
|
||||
"sentinel_off": {
|
||||
"zh": "⚠️ Sentinel 未启用。",
|
||||
"en": "⚠️ Sentinel not enabled.",
|
||||
},
|
||||
"system_prompt": {
|
||||
"zh": "你是 NOFXi,一个专业的 AI 交易 Agent。简洁、专业、用中文回复。使用交易相关 emoji。",
|
||||
"en": "You are NOFXi, a professional AI trading agent. Be concise, professional. Use trading emojis.",
|
||||
},
|
||||
}
|
||||
|
||||
func (a *Agent) msg(lang, key string) string {
|
||||
if m, ok := i18nMessages[key]; ok {
|
||||
if s, ok := m[lang]; ok {
|
||||
return s
|
||||
}
|
||||
if s, ok := m["en"]; ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
type llmSkillRouteDecision struct {
|
||||
Route string `json:"route"`
|
||||
Skill string `json:"skill,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Filter string `json:"filter,omitempty"`
|
||||
}
|
||||
|
||||
func (a *Agent) tryLLMSkillRoute(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
|
||||
if a.aiClient == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
recentConversationCtx := a.buildRecentConversationContext(userID, text)
|
||||
taskStateCtx := buildTaskStateContext(a.getTaskState(userID))
|
||||
executionState := normalizeExecutionState(a.getExecutionState(userID))
|
||||
executionJSON, _ := json.Marshal(executionState)
|
||||
systemPrompt := `You are the lightweight skill router for NOFXi.
|
||||
Decide whether the user's message should go to a structured skill or continue to the planner.
|
||||
Return JSON only. Do not return markdown.
|
||||
|
||||
Use route "skill" only when the user intent is clear enough to send directly to one structured skill.
|
||||
Use route "planner" for ambiguous, multi-step, open-ended, analytical, or diagnostic requests.
|
||||
|
||||
Available skills:
|
||||
- trader_management
|
||||
- exchange_management
|
||||
- model_management
|
||||
- strategy_management
|
||||
- trader_diagnosis
|
||||
- exchange_diagnosis
|
||||
- model_diagnosis
|
||||
- strategy_diagnosis
|
||||
|
||||
For management skills, choose one atomic action from:
|
||||
- query_list
|
||||
- query_detail
|
||||
- query_running
|
||||
- create
|
||||
- update_name
|
||||
- update_bindings
|
||||
- update_status
|
||||
- update_endpoint
|
||||
- update_config
|
||||
- update_prompt
|
||||
- delete
|
||||
- start
|
||||
- stop
|
||||
- activate
|
||||
- duplicate
|
||||
|
||||
Set filter only when it is clearly implied by the user. Use values like:
|
||||
- running_only
|
||||
- stopped_only
|
||||
- enabled_only
|
||||
- disabled_only
|
||||
- active_only
|
||||
- default_only
|
||||
|
||||
Rules:
|
||||
- Prefer route "planner" when uncertain.
|
||||
- Prefer route "planner" for market analysis, broad advice, multi-step troubleshooting, or requests that need synthesis.
|
||||
- Prefer route "skill" for straightforward management requests like listing, creating, starting, stopping, enabling, disabling, renaming, or deleting known entities.
|
||||
- Questions like "当前有运行中的trader吗" and "有没有 trader 在跑" are trader_management with action "query_running".
|
||||
- Questions about one entity's details, config, parameters, or prompt should prefer action "query_detail".
|
||||
- Do not use route "skill" for casual chat.
|
||||
- Consider Recent conversation, Task state, and Execution state JSON before deciding.
|
||||
|
||||
Return JSON with this exact shape:
|
||||
{"route":"skill|planner","skill":"","action":"","filter":""}`
|
||||
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nRecent conversation:\n%s\n\nTask state:\n%s\n\nExecution state JSON:\n%s", lang, text, recentConversationCtx, taskStateCtx, string(executionJSON))
|
||||
|
||||
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
|
||||
defer cancel()
|
||||
|
||||
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
|
||||
Messages: []mcp.Message{
|
||||
mcp.NewSystemMessage(systemPrompt),
|
||||
mcp.NewUserMessage(userPrompt),
|
||||
},
|
||||
Ctx: stageCtx,
|
||||
})
|
||||
if err != nil {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
decision, err := parseLLMSkillRouteDecision(raw)
|
||||
if err != nil || decision.Route != "skill" {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
outcome, ok := a.executeLLMSkillRoute(storeUserID, userID, lang, text, decision)
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
review, err := a.reviewTaskCompletion(ctx, userID, lang, text, outcome)
|
||||
if err != nil {
|
||||
if outcome.Status == skillOutcomeRecoverableError || outcome.Status == skillOutcomeFatalError || outcome.Status == skillOutcomeNotHandled {
|
||||
return "", false, nil
|
||||
}
|
||||
review = taskReviewDecision{Route: "complete", Answer: outcome.UserMessage}
|
||||
}
|
||||
if review.Route == "replan" {
|
||||
answer, planErr := a.runPlannedAgent(ctx, storeUserID, userID, lang, fmt.Sprintf("Original user request:\n%s\n\nPrevious skill outcome JSON:\n%s", text, mustMarshalJSON(outcome)), onEvent)
|
||||
return answer, true, planErr
|
||||
}
|
||||
|
||||
answer := strings.TrimSpace(review.Answer)
|
||||
if answer == "" {
|
||||
answer = strings.TrimSpace(outcome.UserMessage)
|
||||
}
|
||||
if answer == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
a.recordSkillInteraction(userID, text, answer)
|
||||
if onEvent != nil {
|
||||
label := "llm_skill_route"
|
||||
if decision.Skill != "" {
|
||||
label += ":" + decision.Skill
|
||||
}
|
||||
if decision.Action != "" {
|
||||
label += ":" + decision.Action
|
||||
}
|
||||
onEvent(StreamEventTool, label)
|
||||
onEvent(StreamEventDelta, answer)
|
||||
}
|
||||
return answer, true, nil
|
||||
}
|
||||
|
||||
func parseLLMSkillRouteDecision(raw string) (llmSkillRouteDecision, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimPrefix(raw, "```json")
|
||||
raw = strings.TrimPrefix(raw, "```")
|
||||
raw = strings.TrimSuffix(raw, "```")
|
||||
raw = strings.TrimSpace(raw)
|
||||
|
||||
var decision llmSkillRouteDecision
|
||||
if err := json.Unmarshal([]byte(raw), &decision); err == nil {
|
||||
return normalizeLLMSkillRouteDecision(decision), nil
|
||||
}
|
||||
start := strings.Index(raw, "{")
|
||||
end := strings.LastIndex(raw, "}")
|
||||
if start >= 0 && end > start {
|
||||
if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil {
|
||||
return normalizeLLMSkillRouteDecision(decision), nil
|
||||
}
|
||||
}
|
||||
return llmSkillRouteDecision{}, fmt.Errorf("invalid llm skill route json")
|
||||
}
|
||||
|
||||
func normalizeLLMSkillRouteDecision(decision llmSkillRouteDecision) llmSkillRouteDecision {
|
||||
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
|
||||
decision.Skill = strings.TrimSpace(strings.ToLower(decision.Skill))
|
||||
decision.Filter = strings.TrimSpace(strings.ToLower(decision.Filter))
|
||||
if decision.Action == "query" && decision.Filter == "running_only" && decision.Skill == "trader_management" {
|
||||
decision.Action = "query_running"
|
||||
} else {
|
||||
decision.Action = normalizeAtomicSkillAction(decision.Skill, decision.Action)
|
||||
}
|
||||
return decision
|
||||
}
|
||||
|
||||
func (a *Agent) executeLLMSkillRoute(storeUserID string, userID int64, lang, text string, decision llmSkillRouteDecision) (skillOutcome, bool) {
|
||||
session := skillSession{Name: decision.Skill, Action: decision.Action}
|
||||
|
||||
switch decision.Skill {
|
||||
case "trader_management":
|
||||
if decision.Action == "create" {
|
||||
answer, handled := a.handleCreateTraderSkill(storeUserID, userID, lang, text, session)
|
||||
if !handled {
|
||||
return skillOutcome{}, false
|
||||
}
|
||||
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
|
||||
}
|
||||
answer, handled := a.handleTraderManagementSkill(storeUserID, userID, lang, text, session)
|
||||
if handled && decision.Action == "query_running" {
|
||||
answer = applyTraderQueryFilter(lang, answer, a.toolListTraders(storeUserID), "running_only")
|
||||
}
|
||||
if !handled {
|
||||
return skillOutcome{}, false
|
||||
}
|
||||
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
|
||||
case "exchange_management":
|
||||
answer, handled := a.handleExchangeManagementSkill(storeUserID, userID, lang, text, session)
|
||||
if !handled {
|
||||
return skillOutcome{}, false
|
||||
}
|
||||
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
|
||||
case "model_management":
|
||||
answer, handled := a.handleModelManagementSkill(storeUserID, userID, lang, text, session)
|
||||
if !handled {
|
||||
return skillOutcome{}, false
|
||||
}
|
||||
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
|
||||
case "strategy_management":
|
||||
answer, handled := a.handleStrategyManagementSkill(storeUserID, userID, lang, text, session)
|
||||
if !handled {
|
||||
return skillOutcome{}, false
|
||||
}
|
||||
return inferSkillOutcome(decision.Skill, decision.Action, answer, a.getSkillSession(userID), skillDataForAction(storeUserID, decision.Skill, decision.Action, a)), true
|
||||
case "model_diagnosis":
|
||||
return skillOutcome{
|
||||
Skill: decision.Skill,
|
||||
Action: defaultIfEmpty(decision.Action, "diagnose"),
|
||||
Status: skillOutcomeSuccess,
|
||||
GoalAchieved: true,
|
||||
UserMessage: a.handleModelDiagnosisSkill(storeUserID, lang, text),
|
||||
}, true
|
||||
case "exchange_diagnosis":
|
||||
return skillOutcome{
|
||||
Skill: decision.Skill,
|
||||
Action: defaultIfEmpty(decision.Action, "diagnose"),
|
||||
Status: skillOutcomeSuccess,
|
||||
GoalAchieved: true,
|
||||
UserMessage: a.handleExchangeDiagnosisSkill(storeUserID, lang, text),
|
||||
}, true
|
||||
case "trader_diagnosis":
|
||||
return skillOutcome{
|
||||
Skill: decision.Skill,
|
||||
Action: defaultIfEmpty(decision.Action, "diagnose"),
|
||||
Status: skillOutcomeSuccess,
|
||||
GoalAchieved: true,
|
||||
UserMessage: a.handleTraderDiagnosisSkill(storeUserID, lang, text),
|
||||
}, true
|
||||
case "strategy_diagnosis":
|
||||
return skillOutcome{
|
||||
Skill: decision.Skill,
|
||||
Action: defaultIfEmpty(decision.Action, "diagnose"),
|
||||
Status: skillOutcomeSuccess,
|
||||
GoalAchieved: true,
|
||||
UserMessage: a.handleStrategyDiagnosisSkill(storeUserID, lang, text),
|
||||
}, true
|
||||
default:
|
||||
return skillOutcome{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func skillDataForAction(storeUserID, skill, action string, a *Agent) map[string]any {
|
||||
var raw string
|
||||
switch skill {
|
||||
case "trader_management":
|
||||
if strings.HasPrefix(action, "query") {
|
||||
raw = a.toolListTraders(storeUserID)
|
||||
}
|
||||
case "exchange_management":
|
||||
if strings.HasPrefix(action, "query") {
|
||||
raw = a.toolGetExchangeConfigs(storeUserID)
|
||||
}
|
||||
case "model_management":
|
||||
if strings.HasPrefix(action, "query") {
|
||||
raw = a.toolGetModelConfigs(storeUserID)
|
||||
}
|
||||
case "strategy_management":
|
||||
if strings.HasPrefix(action, "query") {
|
||||
raw = a.toolGetStrategies(storeUserID)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func mustMarshalJSON(v any) string {
|
||||
data, _ := json.Marshal(v)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func applyTraderQueryFilter(lang, fallback, raw, filter string) string {
|
||||
filter = strings.TrimSpace(strings.ToLower(filter))
|
||||
if filter == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Traders []struct {
|
||||
Name string `json:"name"`
|
||||
IsRunning bool `json:"is_running"`
|
||||
} `json:"traders"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
switch filter {
|
||||
case "running_only":
|
||||
names := make([]string, 0, len(payload.Traders))
|
||||
for _, trader := range payload.Traders {
|
||||
if trader.IsRunning {
|
||||
names = append(names, strings.TrimSpace(trader.Name))
|
||||
}
|
||||
}
|
||||
if lang == "zh" {
|
||||
if len(names) == 0 {
|
||||
return "当前没有运行中的交易员。"
|
||||
}
|
||||
return fmt.Sprintf("当前有 %d 个运行中的交易员:%s。", len(names), strings.Join(names, "、"))
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "There are no running traders right now."
|
||||
}
|
||||
return fmt.Sprintf("There are %d running traders right now: %s.", len(names), strings.Join(names, ", "))
|
||||
case "stopped_only":
|
||||
names := make([]string, 0, len(payload.Traders))
|
||||
for _, trader := range payload.Traders {
|
||||
if !trader.IsRunning {
|
||||
names = append(names, strings.TrimSpace(trader.Name))
|
||||
}
|
||||
}
|
||||
if lang == "zh" {
|
||||
if len(names) == 0 {
|
||||
return "当前没有已停止的交易员。"
|
||||
}
|
||||
return fmt.Sprintf("当前有 %d 个未运行的交易员:%s。", len(names), strings.Join(names, "、"))
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "There are no stopped traders right now."
|
||||
}
|
||||
return fmt.Sprintf("There are %d stopped traders right now: %s.", len(names), strings.Join(names, ", "))
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
467
agent/memory.go
467
agent/memory.go
@@ -1,467 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
const (
|
||||
recentConversationRounds = 3
|
||||
recentConversationMessages = recentConversationRounds * 2
|
||||
taskStateSummaryTokenLimit = 1200
|
||||
shortTermCompressThreshold = 900
|
||||
incrementalTaskStateMessages = 6
|
||||
incrementalTaskStateTokenLimit = 500
|
||||
)
|
||||
|
||||
type DecisionMemory struct {
|
||||
Action string `json:"action,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
StillValid bool `json:"still_valid,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
}
|
||||
|
||||
type TaskState struct {
|
||||
CurrentGoal string `json:"current_goal,omitempty"`
|
||||
ActiveFlow string `json:"active_flow,omitempty"`
|
||||
// OpenLoops stores only high-level unresolved issues that still matter across turns.
|
||||
// Step-level pending work belongs in ExecutionState, not here.
|
||||
OpenLoops []string `json:"open_loops,omitempty"`
|
||||
ImportantFacts []string `json:"important_facts,omitempty"`
|
||||
LastDecision *DecisionMemory `json:"last_decision,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
func TaskStateConfigKey(userID int64) string {
|
||||
return fmt.Sprintf("agent_task_state_%d", userID)
|
||||
}
|
||||
|
||||
func (a *Agent) getTaskState(userID int64) TaskState {
|
||||
if a.store == nil {
|
||||
return TaskState{}
|
||||
}
|
||||
raw, err := a.store.GetSystemConfig(TaskStateConfigKey(userID))
|
||||
if err != nil {
|
||||
a.logger.Warn("failed to load task state", "error", err, "user_id", userID)
|
||||
return TaskState{}
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return TaskState{}
|
||||
}
|
||||
|
||||
var state TaskState
|
||||
if err := json.Unmarshal([]byte(raw), &state); err != nil {
|
||||
a.logger.Warn("failed to parse task state", "error", err, "user_id", userID)
|
||||
return TaskState{}
|
||||
}
|
||||
return normalizeTaskState(state)
|
||||
}
|
||||
|
||||
func (a *Agent) saveTaskState(userID int64, state TaskState) error {
|
||||
if a.store == nil {
|
||||
return fmt.Errorf("store unavailable")
|
||||
}
|
||||
state = normalizeTaskState(state)
|
||||
if isZeroTaskState(state) {
|
||||
return a.store.SetSystemConfig(TaskStateConfigKey(userID), "")
|
||||
}
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.store.SetSystemConfig(TaskStateConfigKey(userID), string(data))
|
||||
}
|
||||
|
||||
func (a *Agent) clearTaskState(userID int64) {
|
||||
if a.store == nil {
|
||||
return
|
||||
}
|
||||
if err := a.store.SetSystemConfig(TaskStateConfigKey(userID), ""); err != nil {
|
||||
a.logger.Warn("failed to clear task state", "error", err, "user_id", userID)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTaskState(state TaskState) TaskState {
|
||||
state.CurrentGoal = strings.TrimSpace(state.CurrentGoal)
|
||||
state.ActiveFlow = strings.TrimSpace(state.ActiveFlow)
|
||||
state.OpenLoops = filterTaskStateOpenLoops(cleanStringList(state.OpenLoops))
|
||||
state.ImportantFacts = cleanStringList(state.ImportantFacts)
|
||||
if state.LastDecision != nil {
|
||||
state.LastDecision.Action = strings.TrimSpace(state.LastDecision.Action)
|
||||
state.LastDecision.Reason = strings.TrimSpace(state.LastDecision.Reason)
|
||||
state.LastDecision.Timestamp = strings.TrimSpace(state.LastDecision.Timestamp)
|
||||
if state.LastDecision.Timestamp == "" && (state.LastDecision.Action != "" || state.LastDecision.Reason != "") {
|
||||
state.LastDecision.Timestamp = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
if state.LastDecision.Action == "" && state.LastDecision.Reason == "" {
|
||||
state.LastDecision = nil
|
||||
}
|
||||
}
|
||||
if state.UpdatedAt == "" && !isZeroTaskState(state) {
|
||||
state.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func isZeroTaskState(state TaskState) bool {
|
||||
return state.CurrentGoal == "" &&
|
||||
state.ActiveFlow == "" &&
|
||||
len(state.OpenLoops) == 0 &&
|
||||
len(state.ImportantFacts) == 0 &&
|
||||
state.LastDecision == nil
|
||||
}
|
||||
|
||||
func cleanStringList(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, v := range values {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(v)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterTaskStateOpenLoops(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rejectedPrefixes := []string{
|
||||
"wait for ",
|
||||
"waiting for ",
|
||||
"ask for ",
|
||||
"call ",
|
||||
"run ",
|
||||
"execute ",
|
||||
"invoke ",
|
||||
"use tool",
|
||||
"step ",
|
||||
}
|
||||
rejectedContains := []string{
|
||||
"current step",
|
||||
"tool call",
|
||||
"api key",
|
||||
"api secret",
|
||||
"secret key",
|
||||
"passphrase",
|
||||
"model id",
|
||||
"exchange id",
|
||||
}
|
||||
|
||||
filtered := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
lower := strings.ToLower(strings.TrimSpace(value))
|
||||
if lower == "" {
|
||||
continue
|
||||
}
|
||||
if matchesAnyPrefix(lower, rejectedPrefixes) || matchesAnyContains(lower, rejectedContains) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, value)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func matchesAnyPrefix(value string, prefixes []string) bool {
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(value, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchesAnyContains(value string, patterns []string) bool {
|
||||
for _, pattern := range patterns {
|
||||
if strings.Contains(value, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildTaskStateContext(state TaskState) string {
|
||||
state = normalizeTaskState(state)
|
||||
if isZeroTaskState(state) {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("[Structured Task State - durable, non-derivable context]\n")
|
||||
if state.CurrentGoal != "" {
|
||||
sb.WriteString("- Current goal: ")
|
||||
sb.WriteString(state.CurrentGoal)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if state.ActiveFlow != "" {
|
||||
sb.WriteString("- Active flow: ")
|
||||
sb.WriteString(state.ActiveFlow)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
for _, loop := range state.OpenLoops {
|
||||
sb.WriteString("- High-level open loop: ")
|
||||
sb.WriteString(loop)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
for _, fact := range state.ImportantFacts {
|
||||
sb.WriteString("- Important fact: ")
|
||||
sb.WriteString(fact)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if state.LastDecision != nil {
|
||||
sb.WriteString("- Last decision: ")
|
||||
sb.WriteString(state.LastDecision.Action)
|
||||
if state.LastDecision.Reason != "" {
|
||||
sb.WriteString(" | reason: ")
|
||||
sb.WriteString(state.LastDecision.Reason)
|
||||
}
|
||||
if state.LastDecision.StillValid {
|
||||
sb.WriteString(" | still valid")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
func estimateChatMessagesTokens(msgs []chatMessage) int {
|
||||
total := 0
|
||||
for _, msg := range msgs {
|
||||
total += len([]rune(msg.Content))/3 + 10
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func formatChatMessagesForSummary(msgs []chatMessage) string {
|
||||
var sb strings.Builder
|
||||
for _, msg := range msgs {
|
||||
if strings.TrimSpace(msg.Content) == "" {
|
||||
continue
|
||||
}
|
||||
role := "User"
|
||||
if msg.Role == "assistant" {
|
||||
role = "Assistant"
|
||||
}
|
||||
sb.WriteString(role)
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(msg.Content)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
func (a *Agent) maybeCompressHistory(ctx context.Context, userID int64) {
|
||||
if a.aiClient == nil || a.history == nil {
|
||||
return
|
||||
}
|
||||
|
||||
msgs := a.history.Get(userID)
|
||||
if len(msgs) <= recentConversationMessages {
|
||||
return
|
||||
}
|
||||
if estimateChatMessagesTokens(msgs) <= shortTermCompressThreshold {
|
||||
return
|
||||
}
|
||||
|
||||
splitAt := len(msgs) - recentConversationMessages
|
||||
if splitAt <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
oldPart := msgs[:splitAt]
|
||||
recentPart := msgs[splitAt:]
|
||||
existingState := a.getTaskState(userID)
|
||||
updatedState, err := a.summarizeConversationToTaskState(ctx, userID, existingState, oldPart)
|
||||
if err != nil {
|
||||
a.logger.Warn("failed to compress chat history", "error", err, "user_id", userID)
|
||||
return
|
||||
}
|
||||
if err := a.saveTaskState(userID, updatedState); err != nil {
|
||||
a.log().Warn("failed to persist task state", "error", err, "user_id", userID)
|
||||
return
|
||||
}
|
||||
a.history.Replace(userID, recentPart)
|
||||
}
|
||||
|
||||
func (a *Agent) maybeUpdateTaskStateIncrementally(ctx context.Context, userID int64) {
|
||||
if a.aiClient == nil || a.history == nil {
|
||||
return
|
||||
}
|
||||
|
||||
msgs := a.history.Get(userID)
|
||||
if len(msgs) < 2 {
|
||||
return
|
||||
}
|
||||
|
||||
window := msgs
|
||||
if len(window) > incrementalTaskStateMessages {
|
||||
window = window[len(window)-incrementalTaskStateMessages:]
|
||||
}
|
||||
|
||||
existingState := a.getTaskState(userID)
|
||||
updatedState, err := a.summarizeRecentConversationToTaskState(ctx, userID, existingState, window)
|
||||
if err != nil {
|
||||
a.log().Warn("failed to incrementally update task state", "error", err, "user_id", userID)
|
||||
return
|
||||
}
|
||||
if err := a.saveTaskState(userID, updatedState); err != nil {
|
||||
a.log().Warn("failed to persist incremental task state", "error", err, "user_id", userID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) summarizeConversationToTaskState(ctx context.Context, userID int64, existing TaskState, oldPart []chatMessage) (TaskState, error) {
|
||||
transcript := formatChatMessagesForSummary(oldPart)
|
||||
if transcript == "" {
|
||||
return normalizeTaskState(existing), nil
|
||||
}
|
||||
|
||||
existingJSON, err := json.Marshal(normalizeTaskState(existing))
|
||||
if err != nil {
|
||||
return TaskState{}, err
|
||||
}
|
||||
|
||||
systemPrompt := `You maintain structured task state for a trading assistant.
|
||||
Update the task state using the existing state plus archived dialogue.
|
||||
Return JSON only. Do not return markdown.
|
||||
|
||||
Rules:
|
||||
- Keep only durable, non-derivable context useful for future turns.
|
||||
- Do not store market prices, balances, positions, or anything tools can fetch again.
|
||||
- Do not store chit-chat or repeated wording.
|
||||
- current_goal: the user's active objective, if any.
|
||||
- active_flow: a named flow such as onboarding, trading_confirmation, market_analysis, or empty.
|
||||
- open_loops: only high-level unresolved issues that still matter across turns.
|
||||
- Do not put execution-step pending work into open_loops.
|
||||
- Bad open_loops examples: "wait for API secret", "call get_exchange_configs", "run step 2", "ask user for exchange_id".
|
||||
- Good open_loops examples: "finish trader setup after external configuration is ready", "user still wants to complete onboarding".
|
||||
- important_facts: non-derivable facts worth remembering briefly.
|
||||
- last_decision: keep only one current relevant decision; omit if none.
|
||||
- Replace stale items instead of appending blindly.
|
||||
- If a field is no longer relevant, return it empty or omit it.
|
||||
- Never invent facts.`
|
||||
|
||||
userPrompt := fmt.Sprintf("Existing task state JSON:\n%s\n\nArchived dialogue to compress:\n%s\n\nReturn the new task state JSON with this exact shape:\n{\"current_goal\":\"\",\"active_flow\":\"\",\"open_loops\":[],\"important_facts\":[],\"last_decision\":{\"action\":\"\",\"reason\":\"\",\"still_valid\":false,\"timestamp\":\"\"},\"updated_at\":\"\"}", string(existingJSON), transcript)
|
||||
|
||||
req := &mcp.Request{
|
||||
Messages: []mcp.Message{
|
||||
mcp.NewSystemMessage(systemPrompt),
|
||||
mcp.NewUserMessage(userPrompt),
|
||||
},
|
||||
Ctx: ctx,
|
||||
MaxTokens: intPtr(taskStateSummaryTokenLimit),
|
||||
}
|
||||
|
||||
resp, err := a.aiClient.CallWithRequest(req)
|
||||
if err != nil {
|
||||
return TaskState{}, err
|
||||
}
|
||||
|
||||
state, err := parseTaskStateJSON(resp)
|
||||
if err != nil {
|
||||
return TaskState{}, err
|
||||
}
|
||||
state = normalizeTaskState(state)
|
||||
a.log().Info("compressed chat history into task state", "user_id", userID, "archived_messages", len(oldPart))
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (a *Agent) summarizeRecentConversationToTaskState(ctx context.Context, userID int64, existing TaskState, recentPart []chatMessage) (TaskState, error) {
|
||||
transcript := formatChatMessagesForSummary(recentPart)
|
||||
if transcript == "" {
|
||||
return normalizeTaskState(existing), nil
|
||||
}
|
||||
|
||||
existingJSON, err := json.Marshal(normalizeTaskState(existing))
|
||||
if err != nil {
|
||||
return TaskState{}, err
|
||||
}
|
||||
|
||||
systemPrompt := `You maintain structured task state for a trading assistant.
|
||||
Update the task state incrementally using the existing state plus the latest conversation window.
|
||||
Return JSON only. Do not return markdown.
|
||||
|
||||
Rules:
|
||||
- Capture newly confirmed facts from the latest few turns immediately.
|
||||
- Preserve important existing facts that still matter; replace stale items when contradicted.
|
||||
- Keep only durable, non-derivable context useful for the next turns.
|
||||
- current_goal: the user's active objective right now.
|
||||
- active_flow: a named flow such as onboarding, trading_confirmation, market_analysis, strategy_debugging, or empty.
|
||||
- open_loops: only high-level unresolved issues that still matter across turns.
|
||||
- important_facts: include recently confirmed concrete facts, such as the current trader under discussion, the reported runtime error, the user's claimed config value, or the environment where the issue occurs.
|
||||
- Do not store execution-step pending work or tool instructions.
|
||||
- Do not store market prices, balances, or anything tools can fetch again.
|
||||
- Keep last_decision only if there is a current relevant decision; omit it otherwise.
|
||||
- Never invent facts.`
|
||||
|
||||
userPrompt := fmt.Sprintf("Existing task state JSON:\n%s\n\nLatest conversation window:\n%s\n\nReturn the updated task state JSON with this exact shape:\n{\"current_goal\":\"\",\"active_flow\":\"\",\"open_loops\":[],\"important_facts\":[],\"last_decision\":{\"action\":\"\",\"reason\":\"\",\"still_valid\":false,\"timestamp\":\"\"},\"updated_at\":\"\"}", string(existingJSON), transcript)
|
||||
|
||||
req := &mcp.Request{
|
||||
Messages: []mcp.Message{
|
||||
mcp.NewSystemMessage(systemPrompt),
|
||||
mcp.NewUserMessage(userPrompt),
|
||||
},
|
||||
Ctx: ctx,
|
||||
MaxTokens: intPtr(incrementalTaskStateTokenLimit),
|
||||
}
|
||||
|
||||
resp, err := a.aiClient.CallWithRequest(req)
|
||||
if err != nil {
|
||||
return TaskState{}, err
|
||||
}
|
||||
|
||||
state, err := parseTaskStateJSON(resp)
|
||||
if err != nil {
|
||||
return TaskState{}, err
|
||||
}
|
||||
state = normalizeTaskState(state)
|
||||
a.log().Info("incrementally refreshed task state", "user_id", userID, "window_messages", len(recentPart))
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func parseTaskStateJSON(raw string) (TaskState, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimPrefix(raw, "```json")
|
||||
raw = strings.TrimPrefix(raw, "```")
|
||||
raw = strings.TrimSuffix(raw, "```")
|
||||
raw = strings.TrimSpace(raw)
|
||||
|
||||
var state TaskState
|
||||
if err := json.Unmarshal([]byte(raw), &state); err == nil {
|
||||
return state, nil
|
||||
}
|
||||
|
||||
start := strings.Index(raw, "{")
|
||||
end := strings.LastIndex(raw, "}")
|
||||
if start >= 0 && end > start {
|
||||
if err := json.Unmarshal([]byte(raw[start:end+1]), &state); err == nil {
|
||||
return state, nil
|
||||
}
|
||||
}
|
||||
return TaskState{}, fmt.Errorf("invalid task state json")
|
||||
}
|
||||
|
||||
func intPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nofx/mcp"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
type fakeAIClient struct {
|
||||
callCount int
|
||||
}
|
||||
|
||||
func (f *fakeAIClient) SetAPIKey(string, string, string) {}
|
||||
func (f *fakeAIClient) SetTimeout(time.Duration) {}
|
||||
func (f *fakeAIClient) CallWithMessages(string, string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (f *fakeAIClient) CallWithRequest(req *mcp.Request) (string, error) {
|
||||
f.callCount++
|
||||
return `{"current_goal":"continue setup","active_flow":"onboarding","open_loops":["finish trader setup after external exchange/model configuration is ready"],"important_facts":["user selected OKX"],"last_decision":{"action":"paused setup","reason":"user asked a market question","still_valid":true},"updated_at":"2026-04-01T00:00:00Z"}`, nil
|
||||
}
|
||||
func (f *fakeAIClient) CallWithRequestStream(req *mcp.Request, onChunk func(string)) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (f *fakeAIClient) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestMaybeCompressHistoryKeepsRecentThreeRounds(t *testing.T) {
|
||||
st, err := store.New(filepath.Join(t.TempDir(), "nofxi-test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.New() error = %v", err)
|
||||
}
|
||||
|
||||
fakeClient := &fakeAIClient{}
|
||||
a := &Agent{
|
||||
store: st,
|
||||
logger: slog.Default(),
|
||||
history: newChatHistory(100),
|
||||
aiClient: fakeClient,
|
||||
}
|
||||
|
||||
userID := int64(42)
|
||||
payload := strings.Repeat("BTC ETH market context ", 20)
|
||||
for i := 0; i < 6; i++ {
|
||||
a.history.Add(userID, "user", "user turn #"+string(rune('0'+i))+" "+payload)
|
||||
a.history.Add(userID, "assistant", "assistant turn #"+string(rune('0'+i))+" "+payload)
|
||||
}
|
||||
|
||||
a.maybeCompressHistory(context.Background(), userID)
|
||||
|
||||
msgs := a.history.Get(userID)
|
||||
if len(msgs) != recentConversationMessages {
|
||||
t.Fatalf("expected %d recent messages, got %d", recentConversationMessages, len(msgs))
|
||||
}
|
||||
if fakeClient.callCount != 1 {
|
||||
t.Fatalf("expected summarizer to be called once, got %d", fakeClient.callCount)
|
||||
}
|
||||
|
||||
state := a.getTaskState(userID)
|
||||
if state.CurrentGoal != "continue setup" {
|
||||
t.Fatalf("expected persisted task state goal, got %#v", state)
|
||||
}
|
||||
if state.LastDecision == nil || state.LastDecision.Action != "paused setup" {
|
||||
t.Fatalf("expected persisted last_decision, got %#v", state.LastDecision)
|
||||
}
|
||||
if len(state.OpenLoops) != 1 || state.OpenLoops[0] != "finish trader setup after external exchange/model configuration is ready" {
|
||||
t.Fatalf("expected high-level open loop, got %#v", state.OpenLoops)
|
||||
}
|
||||
if strings.Contains(msgs[0].Content, "#0") {
|
||||
t.Fatalf("expected oldest round to be compressed away, first recent message = %q", msgs[0].Content)
|
||||
}
|
||||
if !strings.Contains(msgs[0].Content, "#3") {
|
||||
t.Fatalf("expected recent window to start from round #3, got %q", msgs[0].Content)
|
||||
}
|
||||
if !strings.Contains(msgs[len(msgs)-1].Content, "#5") {
|
||||
t.Fatalf("expected latest round to remain in short-term history, got %q", msgs[len(msgs)-1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTaskStateDropsExecutionLevelOpenLoops(t *testing.T) {
|
||||
state := normalizeTaskState(TaskState{
|
||||
OpenLoops: []string{
|
||||
"wait for API secret",
|
||||
"call get_exchange_configs",
|
||||
"finish trader setup after external configuration is ready",
|
||||
},
|
||||
})
|
||||
|
||||
if len(state.OpenLoops) != 1 {
|
||||
t.Fatalf("expected only one high-level open loop to remain, got %#v", state.OpenLoops)
|
||||
}
|
||||
if state.OpenLoops[0] != "finish trader setup after external configuration is ready" {
|
||||
t.Fatalf("unexpected open loop after normalization: %#v", state.OpenLoops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeUpdateTaskStateIncrementallyPersistsShortConversationFacts(t *testing.T) {
|
||||
st, err := store.New(filepath.Join(t.TempDir(), "nofxi-test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.New() error = %v", err)
|
||||
}
|
||||
|
||||
fakeClient := &fakeAIClient{}
|
||||
a := &Agent{
|
||||
store: st,
|
||||
logger: slog.Default(),
|
||||
history: newChatHistory(100),
|
||||
aiClient: fakeClient,
|
||||
}
|
||||
|
||||
userID := int64(7)
|
||||
a.history.Add(userID, "user", "我是在运行测试1交易员时遇到的,错误是运行时出现的")
|
||||
a.history.Add(userID, "assistant", "我会继续排查测试1交易员的运行时错误")
|
||||
|
||||
a.maybeUpdateTaskStateIncrementally(context.Background(), userID)
|
||||
|
||||
if fakeClient.callCount != 1 {
|
||||
t.Fatalf("expected incremental summarizer to be called once, got %d", fakeClient.callCount)
|
||||
}
|
||||
|
||||
state := a.getTaskState(userID)
|
||||
if state.CurrentGoal != "continue setup" {
|
||||
t.Fatalf("expected incrementally persisted task state, got %#v", state)
|
||||
}
|
||||
}
|
||||
606
agent/onboard.go
606
agent/onboard.go
@@ -1,606 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
var titleCaser = cases.Title(language.English)
|
||||
const setupExchangeAccountName = "Default"
|
||||
|
||||
// Onboard handles first-time setup through natural language.
|
||||
// When there's no trader configured, the agent guides the user.
|
||||
|
||||
// SetupState tracks where the user is in the setup flow.
|
||||
type SetupState struct {
|
||||
Step string // "", "await_exchange", "await_api_key", "await_api_secret", "await_passphrase", "await_ai_model", "await_ai_key"
|
||||
Exchange string
|
||||
ExchangeID string
|
||||
APIKey string
|
||||
APISecret string
|
||||
Passphrase string
|
||||
AIProvider string
|
||||
AIModel string
|
||||
AIModelID string
|
||||
AIKey string
|
||||
AIBaseURL string
|
||||
}
|
||||
|
||||
// needsSetup returns true if no traders are configured.
|
||||
func (a *Agent) needsSetup() bool {
|
||||
if a.traderManager == nil {
|
||||
return true
|
||||
}
|
||||
return len(a.traderManager.GetAllTraders()) == 0
|
||||
}
|
||||
|
||||
// getSetupState loads the current setup state from user preferences.
|
||||
func (a *Agent) getSetupState(userID int64) *SetupState {
|
||||
step, _ := a.store.GetSystemConfig(fmt.Sprintf("setup_step_%d", userID))
|
||||
if step == "" {
|
||||
return &SetupState{}
|
||||
}
|
||||
return &SetupState{
|
||||
Step: step,
|
||||
Exchange: getConfig(a.store, userID, "exchange"),
|
||||
ExchangeID: getConfig(a.store, userID, "exchange_id"),
|
||||
APIKey: getConfig(a.store, userID, "api_key"),
|
||||
APISecret: getConfig(a.store, userID, "api_secret"),
|
||||
Passphrase: getConfig(a.store, userID, "passphrase"),
|
||||
AIProvider: getConfig(a.store, userID, "ai_provider"),
|
||||
AIModel: getConfig(a.store, userID, "ai_model"),
|
||||
AIModelID: getConfig(a.store, userID, "ai_model_id"),
|
||||
AIKey: getConfig(a.store, userID, "ai_key"),
|
||||
AIBaseURL: getConfig(a.store, userID, "ai_base_url"),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) saveSetupState(userID int64, s *SetupState) {
|
||||
a.store.SetSystemConfig(fmt.Sprintf("setup_step_%d", userID), s.Step)
|
||||
setConfig(a.store, userID, "exchange", s.Exchange)
|
||||
setConfig(a.store, userID, "exchange_id", s.ExchangeID)
|
||||
// Store only a masked marker for secrets — full values stay in memory only.
|
||||
// This prevents plaintext credentials from lingering in the config store
|
||||
// if the setup flow is interrupted before clearSetupState runs.
|
||||
if s.APIKey != "" {
|
||||
setConfig(a.store, userID, "api_key", "****")
|
||||
}
|
||||
if s.APISecret != "" {
|
||||
setConfig(a.store, userID, "api_secret", "****")
|
||||
}
|
||||
if s.Passphrase != "" {
|
||||
setConfig(a.store, userID, "passphrase", "****")
|
||||
}
|
||||
setConfig(a.store, userID, "ai_provider", s.AIProvider)
|
||||
setConfig(a.store, userID, "ai_model", s.AIModel)
|
||||
setConfig(a.store, userID, "ai_model_id", s.AIModelID)
|
||||
if s.AIKey != "" {
|
||||
setConfig(a.store, userID, "ai_key", "****")
|
||||
}
|
||||
setConfig(a.store, userID, "ai_base_url", s.AIBaseURL)
|
||||
}
|
||||
|
||||
func (a *Agent) clearSetupState(userID int64) {
|
||||
for _, k := range []string{"step", "exchange", "exchange_id", "api_key", "api_secret", "passphrase", "ai_provider", "ai_model", "ai_model_id", "ai_key", "ai_base_url"} {
|
||||
if err := a.store.SetSystemConfig(fmt.Sprintf("setup_%s_%d", k, userID), ""); err != nil {
|
||||
a.log().Warn("clearSetupState: failed to clear key", "key", k, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getConfig(st *store.Store, uid int64, key string) string {
|
||||
v, _ := st.GetSystemConfig(fmt.Sprintf("setup_%s_%d", key, uid))
|
||||
return v
|
||||
}
|
||||
|
||||
func setConfig(st *store.Store, uid int64, key, val string) {
|
||||
st.SetSystemConfig(fmt.Sprintf("setup_%s_%d", key, uid), val)
|
||||
}
|
||||
|
||||
// handleSetupFlow processes the setup conversation.
|
||||
// Returns (response, handled). If handled=false, continue to normal routing.
|
||||
func (a *Agent) handleSetupFlow(userID int64, text string, L string) (string, bool) {
|
||||
return a.handleSetupFlowForStoreUser("default", userID, text, L)
|
||||
}
|
||||
|
||||
func (a *Agent) handleSetupFlowForStoreUser(storeUserID string, userID int64, text string, L string) (string, bool) {
|
||||
state := a.getSetupState(userID)
|
||||
|
||||
lower := strings.ToLower(text)
|
||||
|
||||
// Cancel setup — explicit or implicit (user asking unrelated questions)
|
||||
if lower == "cancel" || lower == "取消" || lower == "/cancel" {
|
||||
a.clearSetupState(userID)
|
||||
return a.setupMsg(L, "cancelled"), true
|
||||
}
|
||||
|
||||
// If in a step that expects a key/secret, check if user is NOT sending a key
|
||||
// Keys are typically long strings without spaces and Chinese characters
|
||||
if state.Step == "await_api_key" || state.Step == "await_api_secret" || state.Step == "await_passphrase" || state.Step == "await_ai_key" {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
hasChinese := false
|
||||
for _, r := range trimmed {
|
||||
if r >= 0x4e00 && r <= 0x9fff {
|
||||
hasChinese = true
|
||||
break
|
||||
}
|
||||
}
|
||||
hasSpaces := strings.Contains(trimmed, " ") && !strings.HasPrefix(trimmed, "sk-")
|
||||
tooShort := len(trimmed) < 8
|
||||
|
||||
if hasChinese || hasSpaces || tooShort {
|
||||
// User is probably asking a question, not providing a key
|
||||
a.clearSetupState(userID)
|
||||
if L == "zh" {
|
||||
return "👌 配置已暂停。我先回答你的问题——\n\n随时发送 *开始配置* 继续配置。", false
|
||||
}
|
||||
return "👌 Setup paused. Let me answer your question first—\n\nSend *setup* anytime to continue.", false
|
||||
}
|
||||
}
|
||||
|
||||
switch state.Step {
|
||||
case "await_exchange":
|
||||
return a.handleExchangeChoice(userID, text, state, L)
|
||||
case "await_api_key":
|
||||
state.APIKey = strings.TrimSpace(text)
|
||||
state.Step = "await_api_secret"
|
||||
a.saveSetupState(userID, state)
|
||||
return a.setupMsg(L, "ask_secret"), true
|
||||
case "await_api_secret":
|
||||
state.APISecret = strings.TrimSpace(text)
|
||||
// OKX/Bitget/KuCoin need passphrase
|
||||
if needsPassphrase(state.Exchange) {
|
||||
state.Step = "await_passphrase"
|
||||
a.saveSetupState(userID, state)
|
||||
return a.setupMsg(L, "ask_passphrase"), true
|
||||
}
|
||||
exchangeID, err := a.saveSetupExchange(storeUserID, state)
|
||||
if err != nil {
|
||||
a.logger.Error("save exchange from setup failed", "error", err, "exchange", state.Exchange, "store_user_id", storeUserID)
|
||||
if L == "zh" {
|
||||
return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次,或稍后去 Web UI 继续。", err), true
|
||||
}
|
||||
return fmt.Sprintf("⚠️ Failed to save exchange config: %v\nPlease try again, or continue later in the Web UI.", err), true
|
||||
}
|
||||
state.ExchangeID = exchangeID
|
||||
state.Step = "await_ai_model"
|
||||
a.saveSetupState(userID, state)
|
||||
if L == "zh" {
|
||||
return "✅ 交易所配置已保存,在配置页里现在就能看到。\n\n" + a.setupMsg(L, "ask_ai"), true
|
||||
}
|
||||
return "✅ Exchange config saved. It should now be visible in the config page.\n\n" + a.setupMsg(L, "ask_ai"), true
|
||||
case "await_passphrase":
|
||||
state.Passphrase = strings.TrimSpace(text)
|
||||
exchangeID, err := a.saveSetupExchange(storeUserID, state)
|
||||
if err != nil {
|
||||
a.logger.Error("save exchange from setup failed", "error", err, "exchange", state.Exchange, "store_user_id", storeUserID)
|
||||
if L == "zh" {
|
||||
return fmt.Sprintf("⚠️ 交易所配置保存失败: %v\n请再试一次,或稍后去 Web UI 继续。", err), true
|
||||
}
|
||||
return fmt.Sprintf("⚠️ Failed to save exchange config: %v\nPlease try again, or continue later in the Web UI.", err), true
|
||||
}
|
||||
state.ExchangeID = exchangeID
|
||||
state.Step = "await_ai_model"
|
||||
a.saveSetupState(userID, state)
|
||||
if L == "zh" {
|
||||
return "✅ 交易所配置已保存,在配置页里现在就能看到。\n\n" + a.setupMsg(L, "ask_ai"), true
|
||||
}
|
||||
return "✅ Exchange config saved. It should now be visible in the config page.\n\n" + a.setupMsg(L, "ask_ai"), true
|
||||
case "await_ai_model":
|
||||
return a.handleAIChoice(storeUserID, userID, text, state, L)
|
||||
case "await_ai_key":
|
||||
state.AIKey = strings.TrimSpace(text)
|
||||
aiModelID, err := a.saveSetupAIModel(storeUserID, state)
|
||||
if err != nil {
|
||||
a.logger.Error("save AI model from setup failed", "error", err, "provider", state.AIProvider, "store_user_id", storeUserID)
|
||||
if L == "zh" {
|
||||
return fmt.Sprintf("⚠️ AI 模型配置保存失败: %v\n请再试一次,或稍后去 Web UI 继续。", err), true
|
||||
}
|
||||
return fmt.Sprintf("⚠️ Failed to save AI model config: %v\nPlease try again, or continue later in the Web UI.", err), true
|
||||
}
|
||||
state.AIModelID = aiModelID
|
||||
return a.finishSetup(storeUserID, userID, state, L)
|
||||
}
|
||||
|
||||
// Not in setup flow — only enter setup for a tiny set of explicit commands.
|
||||
// Natural-language configuration requests should go to the planner first,
|
||||
// including phrases like "开始配置" or "帮我配置交易所".
|
||||
if isDirectSetupCommand(lower) {
|
||||
state.Step = "await_exchange"
|
||||
a.saveSetupState(userID, state)
|
||||
return a.setupMsg(L, "ask_exchange"), true
|
||||
}
|
||||
|
||||
// Everything else — let normal routing handle it
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isDirectSetupCommand(text string) bool {
|
||||
text = strings.ToLower(strings.TrimSpace(text))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
switch text {
|
||||
case "setup", "/setup", "开始配置", "配置", "开始设置":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) handleExchangeChoice(userID int64, text string, state *SetupState, L string) (string, bool) {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
|
||||
exchanges := map[string]string{
|
||||
"binance": "binance", "币安": "binance", "1": "binance",
|
||||
"okx": "okx", "欧易": "okx", "2": "okx",
|
||||
"bybit": "bybit", "3": "bybit",
|
||||
"bitget": "bitget", "4": "bitget",
|
||||
"gate": "gate", "5": "gate",
|
||||
"kucoin": "kucoin", "库币": "kucoin", "6": "kucoin",
|
||||
"hyperliquid": "hyperliquid", "7": "hyperliquid",
|
||||
}
|
||||
|
||||
ex, ok := exchanges[lower]
|
||||
if !ok {
|
||||
return a.setupMsg(L, "invalid_exchange"), true
|
||||
}
|
||||
|
||||
state.Exchange = ex
|
||||
state.Step = "await_api_key"
|
||||
a.saveSetupState(userID, state)
|
||||
|
||||
if L == "zh" {
|
||||
return fmt.Sprintf("✅ 选择了 *%s*\n\n请发送你的 API Key:", titleCaser.String(ex)), true
|
||||
}
|
||||
return fmt.Sprintf("✅ Selected *%s*\n\nPlease send your API Key:", titleCaser.String(ex)), true
|
||||
}
|
||||
|
||||
func (a *Agent) handleAIChoice(storeUserID string, userID int64, text string, state *SetupState, L string) (string, bool) {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
|
||||
models := map[string]struct{ provider, model, url string }{
|
||||
"deepseek": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"},
|
||||
"1": {"deepseek", "deepseek-chat", "https://api.deepseek.com/v1"},
|
||||
"qwen": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
|
||||
"通义": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
|
||||
"2": {"qwen", "qwen-plus", "https://dashscope.aliyuncs.com/compatible-mode/v1"},
|
||||
"openai": {"openai", "gpt-4o", "https://api.openai.com/v1"},
|
||||
"gpt": {"openai", "gpt-4o", "https://api.openai.com/v1"},
|
||||
"3": {"openai", "gpt-4o", "https://api.openai.com/v1"},
|
||||
"claude": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"},
|
||||
"4": {"claude", "claude-3-5-sonnet-20241022", "https://api.anthropic.com/v1"},
|
||||
"skip": {"", "", ""},
|
||||
"跳过": {"", "", ""},
|
||||
"5": {"", "", ""},
|
||||
}
|
||||
|
||||
choice, ok := models[lower]
|
||||
if !ok {
|
||||
return a.setupMsg(L, "invalid_ai"), true
|
||||
}
|
||||
|
||||
if choice.model == "" {
|
||||
// Skip AI, just create trader with exchange
|
||||
state.AIProvider = ""
|
||||
state.AIModel = ""
|
||||
state.AIModelID = ""
|
||||
state.AIKey = ""
|
||||
return a.finishSetup(storeUserID, userID, state, L)
|
||||
}
|
||||
|
||||
state.AIProvider = choice.provider
|
||||
state.AIModel = choice.model
|
||||
state.AIBaseURL = choice.url
|
||||
state.Step = "await_ai_key"
|
||||
a.saveSetupState(userID, state)
|
||||
|
||||
if L == "zh" {
|
||||
return fmt.Sprintf("✅ AI 模型: *%s*\n\n请发送你的 API Key:", choice.model), true
|
||||
}
|
||||
return fmt.Sprintf("✅ AI Model: *%s*\n\nPlease send your API Key:", choice.model), true
|
||||
}
|
||||
|
||||
func (a *Agent) finishSetup(storeUserID string, userID int64, state *SetupState, L string) (string, bool) {
|
||||
// Create exchange in store
|
||||
a.logger.Info("creating trader from setup",
|
||||
"exchange", state.Exchange,
|
||||
"ai_model", state.AIModel,
|
||||
"store_user_id", storeUserID,
|
||||
)
|
||||
|
||||
// TODO: Use store to create exchange + trader config
|
||||
// For now, log the config and tell user
|
||||
a.clearSetupState(userID)
|
||||
|
||||
result := ""
|
||||
maskedKey := maskKey(state.APIKey)
|
||||
if L == "zh" {
|
||||
result = fmt.Sprintf("🎉 *配置完成!*\n\n"+
|
||||
"• 交易所: %s\n"+
|
||||
"• API Key: %s\n",
|
||||
titleCaser.String(state.Exchange), maskedKey)
|
||||
if state.AIModel != "" {
|
||||
result += fmt.Sprintf("• AI 模型: %s\n", state.AIModel)
|
||||
}
|
||||
result += "\n正在创建 Trader..."
|
||||
} else {
|
||||
result = fmt.Sprintf("🎉 *Setup Complete!*\n\n"+
|
||||
"• Exchange: %s\n"+
|
||||
"• API Key: %s\n",
|
||||
titleCaser.String(state.Exchange), maskedKey)
|
||||
if state.AIModel != "" {
|
||||
result += fmt.Sprintf("• AI Model: %s\n", state.AIModel)
|
||||
}
|
||||
result += "\nCreating Trader..."
|
||||
}
|
||||
|
||||
// Actually create the trader via store
|
||||
err := a.createTraderFromSetupForStoreUser(storeUserID, state)
|
||||
if err != nil {
|
||||
a.logger.Error("create trader failed", "error", err)
|
||||
if L == "zh" {
|
||||
result += fmt.Sprintf("\n\n⚠️ 创建失败: %v\n交易所配置已保存,下次配置时可直接复用。\n也可以在 Web UI 中继续完成。", err)
|
||||
} else {
|
||||
result += fmt.Sprintf("\n\n⚠️ Failed: %v\nYour exchange config was saved, so you can reuse it next time.\nYou can also finish setup in the Web UI.", err)
|
||||
}
|
||||
} else {
|
||||
if L == "zh" {
|
||||
result += "\n\n✅ Trader 已创建!现在你可以:\n• `/analyze BTC` — 分析市场\n• `/positions` — 查看持仓\n• 或者直接跟我聊天"
|
||||
} else {
|
||||
result += "\n\n✅ Trader created! Now you can:\n• `/analyze BTC` — analyze market\n• `/positions` — view positions\n• Or just chat with me"
|
||||
}
|
||||
}
|
||||
|
||||
return result, true
|
||||
}
|
||||
|
||||
func (a *Agent) createTraderFromSetup(state *SetupState) error {
|
||||
return a.createTraderFromSetupForStoreUser("default", state)
|
||||
}
|
||||
|
||||
func (a *Agent) createTraderFromSetupForStoreUser(storeUserID string, state *SetupState) error {
|
||||
if a.store == nil {
|
||||
return fmt.Errorf("store not available")
|
||||
}
|
||||
exchangeID := state.ExchangeID
|
||||
if exchangeID == "" {
|
||||
var err error
|
||||
exchangeID, err = a.saveSetupExchange(storeUserID, state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save exchange: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
aiModelID := state.AIModelID
|
||||
if state.AIModel != "" && state.AIKey != "" && aiModelID == "" {
|
||||
var err error
|
||||
aiModelID, err = a.saveSetupAIModel(storeUserID, state)
|
||||
if err != nil {
|
||||
a.logger.Error("save AI model", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse an existing trader if the same exchange/model pair already exists.
|
||||
existingTraders, err := a.store.Trader().List(storeUserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list traders: %w", err)
|
||||
}
|
||||
for _, existing := range existingTraders {
|
||||
if existing.ExchangeID == exchangeID && existing.AIModelID == aiModelID {
|
||||
a.logger.Info("reusing existing trader created via chat setup",
|
||||
"trader", existing.Name,
|
||||
"exchange_id", exchangeID,
|
||||
"ai_model_id", aiModelID,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create trader config
|
||||
exchangeIDShort := exchangeID
|
||||
if len(exchangeIDShort) > 8 {
|
||||
exchangeIDShort = exchangeIDShort[:8]
|
||||
}
|
||||
modelPart := aiModelID
|
||||
if modelPart == "" {
|
||||
modelPart = "manual"
|
||||
}
|
||||
trader := &store.Trader{
|
||||
ID: fmt.Sprintf("%s_%s_%d", exchangeIDShort, modelPart, time.Now().UnixNano()),
|
||||
Name: fmt.Sprintf("NOFXi-%s", titleCaser.String(state.Exchange)),
|
||||
UserID: storeUserID,
|
||||
ExchangeID: exchangeID,
|
||||
AIModelID: aiModelID,
|
||||
IsRunning: false,
|
||||
}
|
||||
if err := a.store.Trader().Create(trader); err != nil {
|
||||
return fmt.Errorf("save trader: %w", err)
|
||||
}
|
||||
|
||||
a.logger.Info("trader created via chat",
|
||||
"trader", trader.Name,
|
||||
"exchange", state.Exchange,
|
||||
"ai", aiModelID,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) saveSetupExchange(storeUserID string, state *SetupState) (string, error) {
|
||||
if a.store == nil {
|
||||
return "", fmt.Errorf("store not available")
|
||||
}
|
||||
|
||||
hlWallet := ""
|
||||
hlUnified := false
|
||||
passphrase := state.Passphrase
|
||||
apiKey := state.APIKey
|
||||
apiSecret := state.APISecret
|
||||
|
||||
if state.Exchange == "hyperliquid" {
|
||||
hlWallet = state.APISecret
|
||||
apiKey = ""
|
||||
apiSecret = state.APIKey
|
||||
}
|
||||
|
||||
exchanges, err := a.store.Exchange().List(storeUserID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, ex := range exchanges {
|
||||
if ex.ExchangeType == state.Exchange && ex.AccountName == setupExchangeAccountName {
|
||||
if err := a.store.Exchange().Update(
|
||||
storeUserID, ex.ID, true,
|
||||
apiKey, apiSecret, passphrase,
|
||||
false,
|
||||
hlWallet, hlUnified,
|
||||
"", "", "",
|
||||
"", "", "", 0,
|
||||
); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ex.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return a.store.Exchange().Create(
|
||||
storeUserID,
|
||||
state.Exchange,
|
||||
setupExchangeAccountName,
|
||||
true,
|
||||
apiKey, apiSecret, passphrase,
|
||||
false,
|
||||
hlWallet, hlUnified,
|
||||
"", "", "",
|
||||
"", "", "", 0,
|
||||
)
|
||||
}
|
||||
|
||||
func (a *Agent) saveSetupAIModel(storeUserID string, state *SetupState) (string, error) {
|
||||
if a.store == nil {
|
||||
return "", fmt.Errorf("store not available")
|
||||
}
|
||||
if state.AIProvider == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
modelID := state.AIProvider
|
||||
if err := a.store.AIModel().Update(
|
||||
storeUserID,
|
||||
modelID,
|
||||
true,
|
||||
state.AIKey,
|
||||
state.AIBaseURL,
|
||||
state.AIModel,
|
||||
); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
modelID = fmt.Sprintf("%s_%s", storeUserID, state.AIProvider)
|
||||
return modelID, nil
|
||||
}
|
||||
|
||||
func maskKey(key string) string {
|
||||
if len(key) <= 8 {
|
||||
return "****"
|
||||
}
|
||||
return key[:4] + "****" + key[len(key)-4:]
|
||||
}
|
||||
|
||||
func needsPassphrase(exchange string) bool {
|
||||
return exchange == "okx" || exchange == "bitget" || exchange == "kucoin"
|
||||
}
|
||||
|
||||
func containsAny(s string, words []string) bool {
|
||||
for _, w := range words {
|
||||
if strings.Contains(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var setupMessages = map[string]map[string]string{
|
||||
"welcome": {
|
||||
"zh": "👋 你好!我是 *NOFXi*,你的 AI 交易 Agent。\n\n" +
|
||||
"我发现你还没有配置交易所,让我帮你搞定吧!\n\n" +
|
||||
"发送 *开始配置* 或 *setup* 开始\n" +
|
||||
"发送 *取消* 随时退出",
|
||||
"en": "👋 Hi! I'm *NOFXi*, your AI trading agent.\n\n" +
|
||||
"I see you haven't configured an exchange yet. Let me help!\n\n" +
|
||||
"Send *setup* to begin\n" +
|
||||
"Send *cancel* to exit anytime",
|
||||
},
|
||||
"ask_exchange": {
|
||||
"zh": "🏦 *选择你的交易所*\n\n" +
|
||||
"1️⃣ Binance(币安)\n" +
|
||||
"2️⃣ OKX(欧易)\n" +
|
||||
"3️⃣ Bybit\n" +
|
||||
"4️⃣ Bitget\n" +
|
||||
"5️⃣ Gate\n" +
|
||||
"6️⃣ KuCoin(库币)\n" +
|
||||
"7️⃣ Hyperliquid\n\n" +
|
||||
"发送数字或名称选择:",
|
||||
"en": "🏦 *Choose your exchange*\n\n" +
|
||||
"1️⃣ Binance\n" +
|
||||
"2️⃣ OKX\n" +
|
||||
"3️⃣ Bybit\n" +
|
||||
"4️⃣ Bitget\n" +
|
||||
"5️⃣ Gate\n" +
|
||||
"6️⃣ KuCoin\n" +
|
||||
"7️⃣ Hyperliquid\n\n" +
|
||||
"Send number or name:",
|
||||
},
|
||||
"invalid_exchange": {
|
||||
"zh": "❓ 没有识别到交易所。请发送数字 1-7 或交易所名称。",
|
||||
"en": "❓ Exchange not recognized. Send a number 1-7 or exchange name.",
|
||||
},
|
||||
"ask_secret": {
|
||||
"zh": "🔑 收到 API Key。\n\n现在请发送你的 *API Secret*:",
|
||||
"en": "🔑 Got API Key.\n\nNow send your *API Secret*:",
|
||||
},
|
||||
"ask_passphrase": {
|
||||
"zh": "🔐 收到 API Secret。\n\n这个交易所还需要 *Passphrase*,请发送:",
|
||||
"en": "🔐 Got API Secret.\n\nThis exchange also needs a *Passphrase*. Please send it:",
|
||||
},
|
||||
"ask_ai": {
|
||||
"zh": "🤖 *选择 AI 模型*\n\n" +
|
||||
"1️⃣ DeepSeek(推荐,便宜好用)\n" +
|
||||
"2️⃣ 通义千问 (Qwen)\n" +
|
||||
"3️⃣ OpenAI (GPT-4o)\n" +
|
||||
"4️⃣ Claude\n" +
|
||||
"5️⃣ 跳过(不配置 AI)\n\n" +
|
||||
"发送数字或名称选择:",
|
||||
"en": "🤖 *Choose AI model*\n\n" +
|
||||
"1️⃣ DeepSeek (recommended, affordable)\n" +
|
||||
"2️⃣ Qwen\n" +
|
||||
"3️⃣ OpenAI (GPT-4o)\n" +
|
||||
"4️⃣ Claude\n" +
|
||||
"5️⃣ Skip (no AI)\n\n" +
|
||||
"Send number or name:",
|
||||
},
|
||||
"invalid_ai": {
|
||||
"zh": "❓ 没有识别到 AI 模型。请发送数字 1-5 或模型名称。",
|
||||
"en": "❓ AI model not recognized. Send a number 1-5 or model name.",
|
||||
},
|
||||
"cancelled": {
|
||||
"zh": "👌 配置已取消。随时发送 *开始配置* 重新开始。",
|
||||
"en": "👌 Setup cancelled. Send *setup* anytime to restart.",
|
||||
},
|
||||
}
|
||||
|
||||
func (a *Agent) setupMsg(L, key string) string {
|
||||
if m, ok := setupMessages[key]; ok {
|
||||
if s, ok := m[L]; ok {
|
||||
return s
|
||||
}
|
||||
return m["en"]
|
||||
}
|
||||
return key
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsDirectSetupCommand(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{text: "setup", want: true},
|
||||
{text: "/setup", want: true},
|
||||
{text: "开始配置", want: true},
|
||||
{text: "配置", want: true},
|
||||
{text: "开始设置", want: true},
|
||||
{text: "/开始配置", want: false},
|
||||
{text: "创建全新的配置,杠杆你定", want: false},
|
||||
{text: "帮我配置一个 deepseek 模型", want: false},
|
||||
{text: "绑定交易所 okx", want: false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := isDirectSetupCommand(tc.text); got != tc.want {
|
||||
t.Fatalf("isDirectSetupCommand(%q) = %v, want %v", tc.text, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,807 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
func TestIsConfigOrTraderIntent(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{text: "帮我创建一个交易员", want: true},
|
||||
{text: "我已经配置好了 OKX 和 DeepSeek", want: true},
|
||||
{text: "List my traders", want: true},
|
||||
{text: "BTC 接下来怎么看", want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isConfigOrTraderIntent(tc.text); got != tc.want {
|
||||
t.Fatalf("isConfigOrTraderIntent(%q) = %v, want %v", tc.text, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRealtimeAccountIntent(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{text: "现在余额多少", want: true},
|
||||
{text: "我的仓位还在吗", want: true},
|
||||
{text: "show recent trade history", want: true},
|
||||
{text: "帮我创建交易员", want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isRealtimeAccountIntent(tc.text); got != tc.want {
|
||||
t.Fatalf("isRealtimeAccountIntent(%q) = %v, want %v", tc.text, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectReadFastPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
want string
|
||||
}{
|
||||
{text: "/traders", want: "list_traders"},
|
||||
{text: "/strategies", want: "get_strategies"},
|
||||
{text: "/models", want: "get_model_configs"},
|
||||
{text: "/exchanges", want: "get_exchange_configs"},
|
||||
{text: "/balance", want: "get_balance"},
|
||||
{text: "/positions", want: "get_positions"},
|
||||
{text: "/history", want: "get_trade_history"},
|
||||
{text: "/trades", want: "get_trade_history"},
|
||||
{text: "列出我当前的策略", want: ""},
|
||||
{text: "查看当前交易员", want: ""},
|
||||
{text: "现在余额多少", want: ""},
|
||||
{text: "我的仓位还在吗", want: ""},
|
||||
{text: "我现在有哪些账户", want: ""},
|
||||
{text: "我的余额", want: ""},
|
||||
{text: "根据我的余额帮我分析我应该买什么", want: ""},
|
||||
{text: "我的策略是AI100,但是No candidate coins available, cycle skipped", want: ""},
|
||||
{text: "帮我创建一个 trader", want: ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := detectReadFastPath(tc.text)
|
||||
got := ""
|
||||
if req != nil {
|
||||
got = req.Kind
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("detectReadFastPath(%q) = %q, want %q", tc.text, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldResetExecutionStateForNewAttempt(t *testing.T) {
|
||||
state := ExecutionState{
|
||||
SessionID: "sess_1",
|
||||
Status: executionStatusWaitingUser,
|
||||
}
|
||||
if !shouldResetExecutionStateForNewAttempt("我已经配置好了,继续创建交易员", state) {
|
||||
t.Fatalf("expected retry-style config request to reset execution state")
|
||||
}
|
||||
if shouldResetExecutionStateForNewAttempt("BTC 价格多少", state) {
|
||||
t.Fatalf("did not expect generic market query to reset execution state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestAskedQuestion(t *testing.T) {
|
||||
state := ExecutionState{
|
||||
Status: executionStatusWaitingUser,
|
||||
Steps: []PlanStep{
|
||||
{ID: "step_1", Type: planStepTypeTool, Status: planStepStatusCompleted},
|
||||
{ID: "step_2", Type: planStepTypeAskUser, Status: planStepStatusCompleted, Instruction: "需要我用正确的参数重试创建交易员 lky 吗?"},
|
||||
},
|
||||
}
|
||||
got := latestAskedQuestion(state)
|
||||
want := "需要我用正确的参数重试创建交易员 lky 吗?"
|
||||
if got != want {
|
||||
t.Fatalf("latestAskedQuestion() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestAskedQuestionPrefersStructuredWaitingState(t *testing.T) {
|
||||
state := ExecutionState{
|
||||
Status: executionStatusWaitingUser,
|
||||
Waiting: &WaitingState{
|
||||
Question: "请确认是否继续创建交易员 lky",
|
||||
Intent: "confirm_action",
|
||||
},
|
||||
Steps: []PlanStep{
|
||||
{ID: "step_2", Type: planStepTypeAskUser, Status: planStepStatusCompleted, Instruction: "旧问题"},
|
||||
},
|
||||
}
|
||||
if got := latestAskedQuestion(state); got != "请确认是否继续创建交易员 lky" {
|
||||
t.Fatalf("latestAskedQuestion() = %q, want structured waiting question", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshStateForDynamicRequestsAddsFreshSnapshots(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
_ = a.toolManageModelConfig("user-1", `{
|
||||
"action":"create",
|
||||
"provider":"openai",
|
||||
"enabled":true,
|
||||
"custom_api_url":"https://api.openai.com/v1",
|
||||
"custom_model_name":"gpt-5-mini"
|
||||
}`)
|
||||
_ = a.toolManageExchangeConfig("user-1", `{
|
||||
"action":"create",
|
||||
"exchange_type":"okx",
|
||||
"account_name":"Main",
|
||||
"enabled":true
|
||||
}`)
|
||||
|
||||
state := ExecutionState{
|
||||
SessionID: "sess_1",
|
||||
UserID: 1,
|
||||
DynamicSnapshots: []Observation{
|
||||
{Kind: "current_model_configs", Summary: "stale"},
|
||||
},
|
||||
ExecutionLog: []Observation{{Kind: "user_reply", Summary: "continue"}},
|
||||
}
|
||||
|
||||
refreshed := a.refreshStateForDynamicRequests("user-1", "帮我创建交易员", state)
|
||||
|
||||
if len(refreshed.DynamicSnapshots) < 3 {
|
||||
t.Fatalf("expected refreshed observations to include snapshots, got %+v", refreshed.DynamicSnapshots)
|
||||
}
|
||||
|
||||
var foundModel, foundExchange, foundTraders bool
|
||||
for _, obs := range refreshed.DynamicSnapshots {
|
||||
switch obs.Kind {
|
||||
case "current_model_configs":
|
||||
foundModel = strings.Contains(obs.RawJSON, "openai")
|
||||
case "current_exchange_configs":
|
||||
foundExchange = strings.Contains(obs.RawJSON, "okx")
|
||||
case "current_traders":
|
||||
foundTraders = strings.Contains(obs.RawJSON, `"traders"`)
|
||||
}
|
||||
}
|
||||
|
||||
if !foundModel || !foundExchange || !foundTraders {
|
||||
t.Fatalf("missing fresh snapshots: %+v", refreshed.DynamicSnapshots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshStateForRealtimeAccountRequestsAddsFreshSnapshots(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
state := ExecutionState{
|
||||
SessionID: "sess_2",
|
||||
UserID: 1,
|
||||
DynamicSnapshots: []Observation{
|
||||
{Kind: "current_balances", Summary: "stale balances"},
|
||||
{Kind: "current_positions", Summary: "stale positions"},
|
||||
},
|
||||
ExecutionLog: []Observation{{Kind: "user_reply", Summary: "现在余额多少"}},
|
||||
}
|
||||
|
||||
refreshed := a.refreshStateForDynamicRequests("user-1", "现在余额多少,我的仓位还在吗", state)
|
||||
|
||||
var keptBalances, keptPositions, foundHistory bool
|
||||
for _, obs := range refreshed.DynamicSnapshots {
|
||||
switch obs.Kind {
|
||||
case "current_balances":
|
||||
keptBalances = strings.Contains(obs.Summary, "stale balances")
|
||||
case "current_positions":
|
||||
keptPositions = strings.Contains(obs.Summary, "stale positions")
|
||||
case "recent_trade_history":
|
||||
foundHistory = obs.RawJSON != ""
|
||||
}
|
||||
}
|
||||
|
||||
if !keptBalances || !keptPositions || foundHistory {
|
||||
t.Fatalf("expected realtime snapshots to stay untouched, got %+v", refreshed.DynamicSnapshots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkAndActNaturalLanguageReadCanBeHandledByHighLevelSkill(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
_ = a.toolManageStrategy("user-1", `{
|
||||
"action":"create",
|
||||
"name":"激进",
|
||||
"description":"激进策略模板",
|
||||
"lang":"zh"
|
||||
}`)
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 1, "zh", "列出我当前的策略")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "激进") {
|
||||
t.Fatalf("expected natural-language read to be handled by high-level skill, got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeExecutionStateMigratesLegacyObservations(t *testing.T) {
|
||||
state := normalizeExecutionState(ExecutionState{
|
||||
SessionID: "sess_legacy",
|
||||
UserID: 1,
|
||||
Observations: []Observation{
|
||||
{Kind: "tool_result", Summary: "legacy tool result"},
|
||||
},
|
||||
})
|
||||
|
||||
if len(state.Observations) != 0 {
|
||||
t.Fatalf("expected legacy observations field to be cleared, got %+v", state.Observations)
|
||||
}
|
||||
if len(state.ExecutionLog) != 1 || state.ExecutionLog[0].Summary != "legacy tool result" {
|
||||
t.Fatalf("expected legacy observations to migrate into execution log, got %+v", state.ExecutionLog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWaitingStateForTraderConfirmation(t *testing.T) {
|
||||
state := ExecutionState{Goal: "创建交易员 lky"}
|
||||
step := PlanStep{
|
||||
ID: "step_ask_1",
|
||||
Type: planStepTypeAskUser,
|
||||
Instruction: "需要我用正确的参数重试创建交易员 lky 吗?",
|
||||
RequiresConfirmation: true,
|
||||
}
|
||||
|
||||
waiting := buildWaitingState(state, step, step.Instruction)
|
||||
if waiting == nil {
|
||||
t.Fatal("expected waiting state")
|
||||
}
|
||||
if waiting.Intent != "confirm_action" {
|
||||
t.Fatalf("unexpected waiting intent: %+v", waiting)
|
||||
}
|
||||
if waiting.ConfirmationTarget != "trader" {
|
||||
t.Fatalf("unexpected confirmation target: %+v", waiting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWaitingStateCleansFields(t *testing.T) {
|
||||
state := normalizeExecutionState(ExecutionState{
|
||||
SessionID: "sess_waiting",
|
||||
UserID: 1,
|
||||
Waiting: &WaitingState{
|
||||
Question: " 请提供 strategy_id ",
|
||||
Intent: " complete_trader_setup ",
|
||||
PendingFields: []string{" strategy_id ", "strategy_id"},
|
||||
ConfirmationTarget: " trader ",
|
||||
},
|
||||
})
|
||||
|
||||
if state.Waiting == nil {
|
||||
t.Fatal("expected normalized waiting state")
|
||||
}
|
||||
if state.Waiting.Question != "请提供 strategy_id" {
|
||||
t.Fatalf("unexpected normalized question: %+v", state.Waiting)
|
||||
}
|
||||
if len(state.Waiting.PendingFields) != 1 || state.Waiting.PendingFields[0] != "strategy_id" {
|
||||
t.Fatalf("unexpected pending fields: %+v", state.Waiting)
|
||||
}
|
||||
if state.Waiting.ConfirmationTarget != "trader" {
|
||||
t.Fatalf("unexpected confirmation target: %+v", state.Waiting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshCurrentReferencesForUserTextMatchesStrategyName(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
_ = a.toolManageStrategy("user-1", `{
|
||||
"action":"create",
|
||||
"name":"激进",
|
||||
"description":"激进策略模板",
|
||||
"lang":"zh"
|
||||
}`)
|
||||
|
||||
state := newExecutionState(1, "帮我改一下激进这个策略")
|
||||
a.refreshCurrentReferencesForUserText("user-1", "帮我改一下激进这个策略", &state)
|
||||
|
||||
if state.CurrentReferences == nil || state.CurrentReferences.Strategy == nil {
|
||||
t.Fatalf("expected strategy reference, got %+v", state.CurrentReferences)
|
||||
}
|
||||
if state.CurrentReferences.Strategy.Name != "激进" {
|
||||
t.Fatalf("unexpected strategy reference: %+v", state.CurrentReferences.Strategy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCurrentReferencesFromToolResultTracksCreatedStrategy(t *testing.T) {
|
||||
state := newExecutionState(1, "创建策略")
|
||||
changed := updateCurrentReferencesFromToolResult(&state, "manage_strategy", `{
|
||||
"status":"ok",
|
||||
"action":"create",
|
||||
"strategy":{"id":"strategy_1","name":"激进"}
|
||||
}`)
|
||||
|
||||
if !changed {
|
||||
t.Fatalf("expected reference update to report changed")
|
||||
}
|
||||
if state.CurrentReferences == nil || state.CurrentReferences.Strategy == nil {
|
||||
t.Fatalf("expected strategy reference after tool result, got %+v", state.CurrentReferences)
|
||||
}
|
||||
if state.CurrentReferences.Strategy.ID != "strategy_1" {
|
||||
t.Fatalf("unexpected strategy reference: %+v", state.CurrentReferences.Strategy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldAttemptReplan(t *testing.T) {
|
||||
state := ExecutionState{
|
||||
Steps: []PlanStep{
|
||||
{ID: "step_1", Type: planStepTypeTool, Status: planStepStatusCompleted},
|
||||
{ID: "step_2", Type: planStepTypeRespond, Status: planStepStatusPending},
|
||||
},
|
||||
}
|
||||
|
||||
if !shouldAttemptReplan(state, PlanStep{
|
||||
Type: planStepTypeTool,
|
||||
ToolName: "manage_trader",
|
||||
ToolArgs: map[string]any{"action": "create"},
|
||||
OutputSummary: `{"status":"ok","action":"create"}`,
|
||||
}, false) {
|
||||
t.Fatalf("expected create trader step to trigger replan")
|
||||
}
|
||||
|
||||
if shouldAttemptReplan(state, PlanStep{
|
||||
Type: planStepTypeTool,
|
||||
ToolName: "get_balance",
|
||||
OutputSummary: `{"balances":[]}`,
|
||||
}, false) {
|
||||
t.Fatalf("did not expect read-only balance step to trigger replan")
|
||||
}
|
||||
|
||||
if !shouldAttemptReplan(state, PlanStep{
|
||||
Type: planStepTypeTool,
|
||||
ToolName: "get_balance",
|
||||
OutputSummary: `{"error":"ai_model_id is required"}`,
|
||||
}, false) {
|
||||
t.Fatalf("expected dependency/error result to trigger replan")
|
||||
}
|
||||
}
|
||||
|
||||
type failingAIClient struct{}
|
||||
|
||||
func (f *failingAIClient) SetAPIKey(string, string, string) {}
|
||||
func (f *failingAIClient) SetTimeout(_ time.Duration) {}
|
||||
func (f *failingAIClient) CallWithMessages(string, string) (string, error) {
|
||||
return "", errors.New("unexpected CallWithMessages")
|
||||
}
|
||||
func (f *failingAIClient) CallWithRequest(*mcp.Request) (string, error) {
|
||||
return "", errors.New("API returned error (status 402): insufficient balance")
|
||||
}
|
||||
func (f *failingAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
|
||||
return "", errors.New("unexpected CallWithRequestStream")
|
||||
}
|
||||
func (f *failingAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
|
||||
return nil, errors.New("API returned error (status 402): insufficient balance")
|
||||
}
|
||||
|
||||
type capturePlannerAIClient struct {
|
||||
systemPrompt string
|
||||
userPrompt string
|
||||
}
|
||||
|
||||
func (c *capturePlannerAIClient) SetAPIKey(string, string, string) {}
|
||||
func (c *capturePlannerAIClient) SetTimeout(time.Duration) {}
|
||||
func (c *capturePlannerAIClient) CallWithMessages(string, string) (string, error) {
|
||||
return "", errors.New("unexpected CallWithMessages")
|
||||
}
|
||||
func (c *capturePlannerAIClient) CallWithRequest(req *mcp.Request) (string, error) {
|
||||
if len(req.Messages) > 0 {
|
||||
c.systemPrompt = req.Messages[0].Content
|
||||
}
|
||||
if len(req.Messages) > 1 {
|
||||
c.userPrompt = req.Messages[1].Content
|
||||
}
|
||||
return `{"goal":"test goal","steps":[{"id":"step_1","type":"respond","instruction":"ok"}]}`, nil
|
||||
}
|
||||
func (c *capturePlannerAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
|
||||
return "", errors.New("unexpected CallWithRequestStream")
|
||||
}
|
||||
func (c *capturePlannerAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
|
||||
return nil, errors.New("unexpected CallWithRequestFull")
|
||||
}
|
||||
|
||||
type blockingAIClient struct{}
|
||||
|
||||
func (b *blockingAIClient) SetAPIKey(string, string, string) {}
|
||||
func (b *blockingAIClient) SetTimeout(time.Duration) {}
|
||||
func (b *blockingAIClient) CallWithMessages(string, string) (string, error) {
|
||||
return "", errors.New("unexpected CallWithMessages")
|
||||
}
|
||||
func (b *blockingAIClient) CallWithRequest(req *mcp.Request) (string, error) {
|
||||
<-req.Ctx.Done()
|
||||
return "", req.Ctx.Err()
|
||||
}
|
||||
func (b *blockingAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
|
||||
return "", errors.New("unexpected CallWithRequestStream")
|
||||
}
|
||||
func (b *blockingAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
|
||||
return nil, errors.New("unexpected CallWithRequestFull")
|
||||
}
|
||||
|
||||
type directReplyAIClient struct {
|
||||
lastSystemPrompt string
|
||||
lastUserPrompt string
|
||||
routerPrompt string
|
||||
skillRouterPrompt string
|
||||
plannerPrompt string
|
||||
}
|
||||
|
||||
func (d *directReplyAIClient) SetAPIKey(string, string, string) {}
|
||||
func (d *directReplyAIClient) SetTimeout(time.Duration) {}
|
||||
func (d *directReplyAIClient) CallWithMessages(string, string) (string, error) {
|
||||
return "", errors.New("unexpected CallWithMessages")
|
||||
}
|
||||
func (d *directReplyAIClient) CallWithRequest(req *mcp.Request) (string, error) {
|
||||
if len(req.Messages) > 0 {
|
||||
d.lastSystemPrompt = req.Messages[0].Content
|
||||
}
|
||||
if len(req.Messages) > 1 {
|
||||
d.lastUserPrompt = req.Messages[1].Content
|
||||
}
|
||||
if strings.Contains(d.lastSystemPrompt, "first-pass router for NOFXi") {
|
||||
d.routerPrompt = d.lastSystemPrompt
|
||||
if strings.Contains(d.lastUserPrompt, "你好") {
|
||||
return `{"action":"direct_answer","answer":"你好,我在。想聊策略、配置还是排障?"}`, nil
|
||||
}
|
||||
return `{"action":"defer","answer":""}`, nil
|
||||
}
|
||||
if strings.Contains(d.lastSystemPrompt, "lightweight skill router for NOFXi") {
|
||||
d.skillRouterPrompt = d.lastSystemPrompt
|
||||
if strings.Contains(d.lastUserPrompt, "运行中的trader") || strings.Contains(d.lastUserPrompt, "有没有 trader 在跑") {
|
||||
return `{"route":"skill","skill":"trader_management","action":"query","filter":"running_only"}`, nil
|
||||
}
|
||||
return `{"route":"planner","skill":"","action":"","filter":""}`, nil
|
||||
}
|
||||
if strings.Contains(d.lastSystemPrompt, "planning module for NOFXi") {
|
||||
d.plannerPrompt = d.lastSystemPrompt
|
||||
}
|
||||
return `{"goal":"test goal","steps":[{"id":"step_1","type":"respond","instruction":"ok"}]}`, nil
|
||||
}
|
||||
func (d *directReplyAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
|
||||
return "", errors.New("unexpected CallWithRequestStream")
|
||||
}
|
||||
func (d *directReplyAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
|
||||
return nil, errors.New("unexpected CallWithRequestFull")
|
||||
}
|
||||
|
||||
func TestThinkAndActLegacyReturnsProviderFailureInsteadOfNoAIFallback(t *testing.T) {
|
||||
a := &Agent{
|
||||
aiClient: &failingAIClient{},
|
||||
config: DefaultConfig(),
|
||||
logger: slog.Default(),
|
||||
history: newChatHistory(10),
|
||||
}
|
||||
|
||||
resp, err := a.thinkAndActLegacy(context.Background(), 42, "zh", "你好", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndActLegacy() error = %v", err)
|
||||
}
|
||||
if strings.Contains(resp, "发送 *开始配置* 配置 AI 模型") {
|
||||
t.Fatalf("expected provider failure message, got fallback: %q", resp)
|
||||
}
|
||||
if !strings.Contains(resp, "AI 服务调用失败") {
|
||||
t.Fatalf("expected provider failure message, got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkAndActUsesDirectReplyGateForConversationalQuestion(t *testing.T) {
|
||||
client := &directReplyAIClient{}
|
||||
a := &Agent{
|
||||
aiClient: client,
|
||||
config: DefaultConfig(),
|
||||
logger: slog.Default(),
|
||||
history: newChatHistory(10),
|
||||
}
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 88, "zh", "你好")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "你好,我在") {
|
||||
t.Fatalf("expected direct reply response, got %q", resp)
|
||||
}
|
||||
if !strings.Contains(client.routerPrompt, "first-pass router for NOFXi") {
|
||||
t.Fatalf("expected direct reply router prompt, got %q", client.routerPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkAndActDefersFromDirectReplyGateToHardSkill(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
a.aiClient = &directReplyAIClient{}
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 89, "zh", "帮我创建一个 DeepSeek 模型配置")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已创建模型配置") {
|
||||
t.Fatalf("expected direct reply gate to defer to hard skill, got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkAndActUsesLLMSkillRouterForNaturalLanguageTraderQuery(t *testing.T) {
|
||||
client := &directReplyAIClient{}
|
||||
a := newTestAgentWithStore(t)
|
||||
a.aiClient = client
|
||||
a.history = newChatHistory(10)
|
||||
|
||||
modelResp := a.toolManageModelConfig("user-1", `{
|
||||
"action":"create",
|
||||
"provider":"openai",
|
||||
"enabled":true,
|
||||
"custom_api_url":"https://api.openai.com/v1",
|
||||
"custom_model_name":"gpt-5-mini"
|
||||
}`)
|
||||
var modelCreated struct {
|
||||
Model safeModelToolConfig `json:"model"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil {
|
||||
t.Fatalf("unmarshal model response: %v", err)
|
||||
}
|
||||
|
||||
exchangeResp := a.toolManageExchangeConfig("user-1", `{
|
||||
"action":"create",
|
||||
"exchange_type":"binance",
|
||||
"account_name":"Main",
|
||||
"enabled":true
|
||||
}`)
|
||||
var exchangeCreated struct {
|
||||
Exchange safeExchangeToolConfig `json:"exchange"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil {
|
||||
t.Fatalf("unmarshal exchange response: %v", err)
|
||||
}
|
||||
|
||||
createResp := a.toolManageTrader("user-1", `{
|
||||
"action":"create",
|
||||
"name":"Momentum Trader",
|
||||
"ai_model_id":"`+modelCreated.Model.ID+`",
|
||||
"exchange_id":"`+exchangeCreated.Exchange.ID+`",
|
||||
"scan_interval_minutes":5
|
||||
}`)
|
||||
var created struct {
|
||||
Trader safeTraderToolConfig `json:"trader"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
|
||||
t.Fatalf("unmarshal create trader response: %v\nraw=%s", err, createResp)
|
||||
}
|
||||
if err := a.store.Trader().UpdateStatus("user-1", created.Trader.ID, true); err != nil {
|
||||
t.Fatalf("update trader status: %v", err)
|
||||
}
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 90, "zh", "当前有运行中的trader吗")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "运行中的交易员") || !strings.Contains(resp, "Momentum Trader") {
|
||||
t.Fatalf("expected routed running-trader answer, got %q", resp)
|
||||
}
|
||||
if client.skillRouterPrompt == "" {
|
||||
t.Fatal("expected lightweight skill router prompt to be used")
|
||||
}
|
||||
if client.plannerPrompt != "" {
|
||||
t.Fatalf("expected planner to be skipped, got prompt %q", client.plannerPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkAndActPrioritizesActiveExecutionStateOverDirectReply(t *testing.T) {
|
||||
client := &directReplyAIClient{}
|
||||
a := newTestAgentWithStore(t)
|
||||
a.aiClient = client
|
||||
a.history = newChatHistory(10)
|
||||
a.logger = slog.Default()
|
||||
|
||||
userID := int64(90)
|
||||
state := newExecutionState(userID, "继续完成当前任务")
|
||||
state.Status = executionStatusWaitingUser
|
||||
state.Waiting = &WaitingState{
|
||||
Question: "请确认是否继续",
|
||||
Intent: "confirm_action",
|
||||
}
|
||||
if err := a.saveExecutionState(state); err != nil {
|
||||
t.Fatalf("saveExecutionState() error = %v", err)
|
||||
}
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", userID, "zh", "你好")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() error = %v", err)
|
||||
}
|
||||
if strings.Contains(resp, "你好,我在") {
|
||||
t.Fatalf("expected active execution state to bypass direct reply gate, got %q", resp)
|
||||
}
|
||||
if !strings.Contains(client.plannerPrompt, "planning module for NOFXi") {
|
||||
t.Fatalf("expected planner prompt when execution state is active, got %q", client.plannerPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkAndActInterruptsWaitingExecutionStateForNewTopic(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
a.history = newChatHistory(10)
|
||||
|
||||
_ = a.toolManageStrategy("user-1", `{
|
||||
"action":"create",
|
||||
"name":"激进",
|
||||
"lang":"zh"
|
||||
}`)
|
||||
|
||||
userID := int64(91)
|
||||
state := newExecutionState(userID, "创建交易员")
|
||||
state.Status = executionStatusWaitingUser
|
||||
state.Waiting = &WaitingState{
|
||||
Question: "请告诉我交易员名称",
|
||||
PendingFields: []string{"name"},
|
||||
}
|
||||
if err := a.saveExecutionState(state); err != nil {
|
||||
t.Fatalf("saveExecutionState() error = %v", err)
|
||||
}
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", userID, "zh", "列出我当前的策略")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "激进") {
|
||||
t.Fatalf("expected new topic to be handled, got %q", resp)
|
||||
}
|
||||
if got := a.getExecutionState(userID); got.SessionID != "" {
|
||||
t.Fatalf("expected execution state to be cleared, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExecutionPlanIncludesRecentConversation(t *testing.T) {
|
||||
client := &capturePlannerAIClient{}
|
||||
a := &Agent{
|
||||
aiClient: client,
|
||||
config: DefaultConfig(),
|
||||
logger: slog.Default(),
|
||||
history: newChatHistory(10),
|
||||
}
|
||||
|
||||
userID := int64(42)
|
||||
a.history.Add(userID, "user", "先帮我看一下当前trader")
|
||||
a.history.Add(userID, "assistant", "当前只有测试1这个trader。")
|
||||
a.history.Add(userID, "user", "好的,那就按当前trader来")
|
||||
|
||||
_, err := a.createExecutionPlan(context.Background(), userID, "zh", "好的,那就按当前trader来", newExecutionState(userID, "好的,那就按当前trader来"))
|
||||
if err != nil {
|
||||
t.Fatalf("createExecutionPlan() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(client.userPrompt, "Recent conversation:") {
|
||||
t.Fatalf("expected planner prompt to include recent conversation, got %q", client.userPrompt)
|
||||
}
|
||||
if !strings.Contains(client.userPrompt, "先帮我看一下当前trader") {
|
||||
t.Fatalf("expected previous user turn in recent conversation, got %q", client.userPrompt)
|
||||
}
|
||||
if !strings.Contains(client.userPrompt, "当前只有测试1这个trader") {
|
||||
t.Fatalf("expected previous assistant turn in recent conversation, got %q", client.userPrompt)
|
||||
}
|
||||
recentIdx := strings.Index(client.userPrompt, "Recent conversation:\n")
|
||||
toolsIdx := strings.Index(client.userPrompt, "\n\nAvailable tools JSON:")
|
||||
if recentIdx == -1 || toolsIdx == -1 || toolsIdx <= recentIdx {
|
||||
t.Fatalf("expected recent conversation block boundaries, got %q", client.userPrompt)
|
||||
}
|
||||
recentBlock := client.userPrompt[recentIdx:toolsIdx]
|
||||
if strings.Contains(recentBlock, "好的,那就按当前trader来") {
|
||||
t.Fatalf("expected current user text to stay out of recent conversation block, got %q", recentBlock)
|
||||
}
|
||||
if !strings.Contains(client.systemPrompt, "Memory priority order:") {
|
||||
t.Fatalf("expected planner system prompt to include memory priority guidance, got %q", client.systemPrompt)
|
||||
}
|
||||
if !strings.Contains(client.systemPrompt, "Execution state JSON = current operational truth") {
|
||||
t.Fatalf("expected planner system prompt to prioritize execution state, got %q", client.systemPrompt)
|
||||
}
|
||||
if !strings.Contains(client.systemPrompt, "Do not ask the user to repeat a fact") {
|
||||
t.Fatalf("expected planner system prompt to forbid unnecessary repeated questions, got %q", client.systemPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExecutionPlanIncludesRecentConversationForFreshRequest(t *testing.T) {
|
||||
client := &capturePlannerAIClient{}
|
||||
a := &Agent{
|
||||
aiClient: client,
|
||||
config: DefaultConfig(),
|
||||
logger: slog.Default(),
|
||||
history: newChatHistory(10),
|
||||
}
|
||||
|
||||
userID := int64(99)
|
||||
a.history.Add(userID, "user", "先帮我看一下当前trader")
|
||||
a.history.Add(userID, "assistant", "当前只有测试1这个trader。")
|
||||
|
||||
_, err := a.createExecutionPlan(context.Background(), userID, "zh", "帮我分析一下比特币", ExecutionState{})
|
||||
if err != nil {
|
||||
t.Fatalf("createExecutionPlan() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(client.userPrompt, "Recent conversation:") {
|
||||
t.Fatalf("expected fresh request to still include recent conversation block, got %q", client.userPrompt)
|
||||
}
|
||||
if !strings.Contains(client.userPrompt, "先帮我看一下当前trader") {
|
||||
t.Fatalf("expected previous user turn in recent conversation, got %q", client.userPrompt)
|
||||
}
|
||||
if !strings.Contains(client.userPrompt, "当前只有测试1这个trader") {
|
||||
t.Fatalf("expected previous assistant turn in recent conversation, got %q", client.userPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExecutionPlanIncludesQuotedEarlierAssistantClaim(t *testing.T) {
|
||||
client := &capturePlannerAIClient{}
|
||||
a := &Agent{
|
||||
aiClient: client,
|
||||
config: DefaultConfig(),
|
||||
logger: slog.Default(),
|
||||
history: newChatHistory(10),
|
||||
}
|
||||
|
||||
userID := int64(100)
|
||||
a.history.Add(userID, "user", "配置页怎么只有三个交易所")
|
||||
a.history.Add(userID, "assistant", "目前你看到的是三个交易所。")
|
||||
|
||||
_, err := a.createExecutionPlan(context.Background(), userID, "zh", "你前面也跟我说只有三个交易所", ExecutionState{})
|
||||
if err != nil {
|
||||
t.Fatalf("createExecutionPlan() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(client.userPrompt, "目前你看到的是三个交易所") {
|
||||
t.Fatalf("expected planner prompt to include earlier assistant claim, got %q", client.userPrompt)
|
||||
}
|
||||
if !strings.Contains(client.userPrompt, "配置页怎么只有三个交易所") {
|
||||
t.Fatalf("expected planner prompt to include earlier user complaint, got %q", client.userPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPlannedAgentReturnsTimeoutMessageOnPlannerTimeout(t *testing.T) {
|
||||
oldTimeout := plannerCreateTimeout
|
||||
plannerCreateTimeout = 10 * time.Millisecond
|
||||
defer func() { plannerCreateTimeout = oldTimeout }()
|
||||
|
||||
a := &Agent{
|
||||
aiClient: &blockingAIClient{},
|
||||
config: DefaultConfig(),
|
||||
logger: slog.Default(),
|
||||
history: newChatHistory(10),
|
||||
}
|
||||
|
||||
resp, err := a.runPlannedAgent(context.Background(), "default", 7, "zh", "帮我分析一下当前市场", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("runPlannedAgent() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "处理超时") {
|
||||
t.Fatalf("expected timeout message, got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleMessageForStoreUserBypassesPlannerForTradeConfirmation(t *testing.T) {
|
||||
a := &Agent{
|
||||
config: DefaultConfig(),
|
||||
logger: slog.Default(),
|
||||
history: newChatHistory(10),
|
||||
pending: newPendingTrades(),
|
||||
}
|
||||
|
||||
resp, err := a.handleMessageForStoreUser(context.Background(), "default", 1, "确认 trade_missing")
|
||||
if err != nil {
|
||||
t.Fatalf("handleMessageForStoreUser() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "交易已过期或不存在") {
|
||||
t.Fatalf("expected direct trade confirmation handling, got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveModelRuntimeConfigUsesProviderDefaults(t *testing.T) {
|
||||
url, model := resolveModelRuntimeConfig("deepseek", "", "", "user_deepseek")
|
||||
if url != "https://api.deepseek.com/v1" {
|
||||
t.Fatalf("unexpected deepseek default url: %q", url)
|
||||
}
|
||||
if model != "deepseek-chat" {
|
||||
t.Fatalf("unexpected deepseek default model: %q", model)
|
||||
}
|
||||
|
||||
url, model = resolveModelRuntimeConfig("deepseek", "", "deepseek1", "user_deepseek")
|
||||
if url != "https://api.deepseek.com/v1" {
|
||||
t.Fatalf("unexpected resolved url: %q", url)
|
||||
}
|
||||
if model != "deepseek1" {
|
||||
t.Fatalf("expected existing custom model name to win, got %q", model)
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PersistentPreference is a durable user instruction shown in the UI and
|
||||
// injected into the agent context for future conversations.
|
||||
type PersistentPreference struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
func NewPersistentPreference(text string) (PersistentPreference, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return PersistentPreference{}, fmt.Errorf("text required")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
return PersistentPreference{
|
||||
ID: now.Format("20060102150405.000000000"),
|
||||
Text: text,
|
||||
CreatedAt: now.Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SessionUserIDFromKey maps a stable user key (for example a UUID string from
|
||||
// auth) to the int64 session id expected by the current agent implementation.
|
||||
func SessionUserIDFromKey(userKey string) int64 {
|
||||
if strings.TrimSpace(userKey) == "" {
|
||||
return 1
|
||||
}
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(userKey))
|
||||
sum := h.Sum64() & 0x7fffffffffffffff
|
||||
if sum == 0 {
|
||||
return 1
|
||||
}
|
||||
return int64(sum)
|
||||
}
|
||||
|
||||
func PreferencesConfigKey(userID int64) string {
|
||||
return fmt.Sprintf("agent_preferences_%d", userID)
|
||||
}
|
||||
|
||||
func (a *Agent) getPersistentPreferences(userID int64) []PersistentPreference {
|
||||
if a.store == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
raw, err := a.store.GetSystemConfig(PreferencesConfigKey(userID))
|
||||
if err != nil || strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var prefs []PersistentPreference
|
||||
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
|
||||
a.logger.Warn("failed to parse persistent preferences", "error", err, "user_id", userID)
|
||||
return nil
|
||||
}
|
||||
return prefs
|
||||
}
|
||||
|
||||
func (a *Agent) savePersistentPreferences(userID int64, prefs []PersistentPreference) error {
|
||||
if a.store == nil {
|
||||
return fmt.Errorf("store unavailable")
|
||||
}
|
||||
data, err := json.Marshal(prefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.store.SetSystemConfig(PreferencesConfigKey(userID), string(data))
|
||||
}
|
||||
|
||||
func (a *Agent) addPersistentPreference(userID int64, text string) ([]PersistentPreference, PersistentPreference, error) {
|
||||
created, err := NewPersistentPreference(text)
|
||||
if err != nil {
|
||||
return nil, PersistentPreference{}, err
|
||||
}
|
||||
prefs := a.getPersistentPreferences(userID)
|
||||
prefs = append([]PersistentPreference{created}, prefs...)
|
||||
if len(prefs) > 20 {
|
||||
prefs = prefs[:20]
|
||||
}
|
||||
if err := a.savePersistentPreferences(userID, prefs); err != nil {
|
||||
return nil, PersistentPreference{}, err
|
||||
}
|
||||
return prefs, created, nil
|
||||
}
|
||||
|
||||
func (a *Agent) updatePersistentPreference(userID int64, match, replacement string) ([]PersistentPreference, *PersistentPreference, error) {
|
||||
match = strings.TrimSpace(match)
|
||||
replacement = strings.TrimSpace(replacement)
|
||||
if match == "" || replacement == "" {
|
||||
return nil, nil, fmt.Errorf("match and replacement are required")
|
||||
}
|
||||
|
||||
prefs := a.getPersistentPreferences(userID)
|
||||
for i := range prefs {
|
||||
if prefs[i].ID == match || strings.Contains(strings.ToLower(prefs[i].Text), strings.ToLower(match)) {
|
||||
prefs[i].Text = replacement
|
||||
if err := a.savePersistentPreferences(userID, prefs); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return prefs, &prefs[i], nil
|
||||
}
|
||||
}
|
||||
return prefs, nil, fmt.Errorf("preference not found")
|
||||
}
|
||||
|
||||
func (a *Agent) deletePersistentPreference(userID int64, match string) ([]PersistentPreference, *PersistentPreference, error) {
|
||||
match = strings.TrimSpace(match)
|
||||
if match == "" {
|
||||
return nil, nil, fmt.Errorf("match required")
|
||||
}
|
||||
|
||||
prefs := a.getPersistentPreferences(userID)
|
||||
filtered := make([]PersistentPreference, 0, len(prefs))
|
||||
var removed *PersistentPreference
|
||||
for i := range prefs {
|
||||
p := prefs[i]
|
||||
if removed == nil && (p.ID == match || strings.Contains(strings.ToLower(p.Text), strings.ToLower(match))) {
|
||||
cp := p
|
||||
removed = &cp
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
if removed == nil {
|
||||
return prefs, nil, fmt.Errorf("preference not found")
|
||||
}
|
||||
if err := a.savePersistentPreferences(userID, filtered); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return filtered, removed, nil
|
||||
}
|
||||
|
||||
func (a *Agent) buildPersistentPreferencesContext(userID int64) string {
|
||||
prefs := a.getPersistentPreferences(userID)
|
||||
if len(prefs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("[Persistent User Preferences - follow unless the user explicitly overrides them]\n")
|
||||
for _, pref := range prefs {
|
||||
if strings.TrimSpace(pref.Text) == "" {
|
||||
continue
|
||||
}
|
||||
sb.WriteString("- ")
|
||||
sb.WriteString(pref.Text)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewPersistentPreference(t *testing.T) {
|
||||
pref, err := NewPersistentPreference(" Always answer in Chinese. ")
|
||||
if err != nil {
|
||||
t.Fatalf("expected preference to be created, got error: %v", err)
|
||||
}
|
||||
if pref.ID == "" {
|
||||
t.Fatal("expected non-empty preference id")
|
||||
}
|
||||
if pref.Text != "Always answer in Chinese." {
|
||||
t.Fatalf("expected trimmed text, got %q", pref.Text)
|
||||
}
|
||||
if pref.CreatedAt == "" {
|
||||
t.Fatal("expected created_at to be set")
|
||||
}
|
||||
if strings.Contains(pref.ID, "Always") {
|
||||
t.Fatalf("expected generated id, got %q", pref.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPersistentPreferenceRejectsEmptyText(t *testing.T) {
|
||||
if _, err := NewPersistentPreference(" "); err == nil {
|
||||
t.Fatal("expected empty text to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"nofx/safe"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Scheduler struct {
|
||||
agent *Agent
|
||||
logger *slog.Logger
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
func NewScheduler(a *Agent, l *slog.Logger) *Scheduler {
|
||||
return &Scheduler{agent: a, logger: l, stopCh: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start(ctx context.Context) {
|
||||
safe.GoNamed("agent-scheduler", func() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
lastReport := time.Time{}
|
||||
lastCheck := time.Time{}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done(): return
|
||||
case <-s.stopCh: return
|
||||
case now := <-ticker.C:
|
||||
// Daily report at 21:00
|
||||
if now.Hour() == 21 && now.Sub(lastReport) > 12*time.Hour {
|
||||
s.dailyReport()
|
||||
lastReport = now
|
||||
}
|
||||
// Position risk check every 4h
|
||||
if now.Sub(lastCheck) > 4*time.Hour {
|
||||
s.riskCheck()
|
||||
lastCheck = now
|
||||
}
|
||||
// Clean expired pending trades every hour.
|
||||
if now.Minute() == 0 {
|
||||
if s.agent.pending != nil {
|
||||
s.agent.pending.CleanExpired()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop() { s.stopOnce.Do(func() { close(s.stopCh) }) }
|
||||
|
||||
func (s *Scheduler) dailyReport() {
|
||||
if s.agent.traderManager == nil { return }
|
||||
|
||||
traders := s.agent.traderManager.GetAllTraders()
|
||||
if len(traders) == 0 { return }
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("📊 *NOFXi 每日报告 — %s*\n\n", time.Now().Format("2006-01-02")))
|
||||
|
||||
totalPnL := 0.0
|
||||
for _, t := range traders {
|
||||
info, err := t.GetAccountInfo()
|
||||
if err != nil { continue }
|
||||
equity := toFloat(info["total_equity"])
|
||||
pnl := toFloat(info["unrealized_pnl"])
|
||||
sb.WriteString(fmt.Sprintf("• %s: $%.2f (P/L: $%.2f)\n", t.GetName(), equity, pnl))
|
||||
totalPnL += pnl
|
||||
}
|
||||
e := "📈"
|
||||
if totalPnL < 0 { e = "📉" }
|
||||
sb.WriteString(fmt.Sprintf("\n%s Total P/L: $%.2f", e, totalPnL))
|
||||
|
||||
s.agent.notifyAll(sb.String())
|
||||
}
|
||||
|
||||
func (s *Scheduler) riskCheck() {
|
||||
if s.agent.traderManager == nil { return }
|
||||
|
||||
var alerts []string
|
||||
for _, t := range s.agent.traderManager.GetAllTraders() {
|
||||
positions, err := t.GetPositions()
|
||||
if err != nil { continue }
|
||||
for _, p := range positions {
|
||||
pnl := toFloat(p["unrealizedPnl"])
|
||||
size := toFloat(p["size"])
|
||||
if size == 0 { continue }
|
||||
entry := toFloat(p["entryPrice"])
|
||||
if entry > 0 {
|
||||
pnlPct := (pnl / (entry * size)) * 100
|
||||
if pnlPct < -5 {
|
||||
alerts = append(alerts, fmt.Sprintf("⚠️ *%s* %s: %.1f%% ($%.2f)",
|
||||
p["symbol"], p["side"], pnlPct, pnl))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(alerts) > 0 {
|
||||
s.agent.notifyAll("🚨 *持仓风险提醒*\n\n" + strings.Join(alerts, "\n"))
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"nofx/safe"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SignalType string
|
||||
|
||||
const (
|
||||
SignalPriceBreakout SignalType = "price_breakout"
|
||||
SignalVolumeSpike SignalType = "volume_spike"
|
||||
SignalFundingRate SignalType = "funding_rate"
|
||||
)
|
||||
|
||||
type Signal struct {
|
||||
Type SignalType
|
||||
Symbol string
|
||||
Severity string
|
||||
Title string
|
||||
Detail string
|
||||
Price float64
|
||||
Change float64
|
||||
}
|
||||
|
||||
type SignalCallback func(Signal)
|
||||
|
||||
type Sentinel struct {
|
||||
mu sync.RWMutex
|
||||
symbols []string
|
||||
history map[string][]pricePt
|
||||
onSignal SignalCallback
|
||||
http *http.Client
|
||||
logger *slog.Logger
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
type pricePt struct {
|
||||
Price float64
|
||||
Volume float64
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
func NewSentinel(symbols []string, cb SignalCallback, logger *slog.Logger) *Sentinel {
|
||||
return &Sentinel{
|
||||
symbols: symbols,
|
||||
history: make(map[string][]pricePt),
|
||||
onSignal: cb,
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
logger: logger,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sentinel) Start() {
|
||||
safe.GoNamed("sentinel", func() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
s.scan()
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.scan()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Sentinel) Stop() { s.stopOnce.Do(func() { close(s.stopCh) }) }
|
||||
func (s *Sentinel) SymbolCount() int { s.mu.RLock(); defer s.mu.RUnlock(); return len(s.symbols) }
|
||||
func (s *Sentinel) AddSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for _, x := range s.symbols { if x == sym { return } }; s.symbols = append(s.symbols, sym) }
|
||||
func (s *Sentinel) RemoveSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for i, x := range s.symbols { if x == sym { s.symbols = append(s.symbols[:i], s.symbols[i+1:]...); return } } }
|
||||
|
||||
func (s *Sentinel) FormatWatchlist(L string) string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if len(s.symbols) == 0 {
|
||||
if L == "zh" { return "📭 监控列表为空。用 `/watch BTC` 添加。" }
|
||||
return "📭 Watchlist empty. Use `/watch BTC` to add."
|
||||
}
|
||||
var sb strings.Builder
|
||||
if L == "zh" { sb.WriteString("👁️ *监控列表*\n\n") } else { sb.WriteString("👁️ *Watchlist*\n\n") }
|
||||
for _, sym := range s.symbols {
|
||||
if pts, ok := s.history[sym]; ok && len(pts) > 0 {
|
||||
last := pts[len(pts)-1]
|
||||
sb.WriteString(fmt.Sprintf("• *%s*: $%.4f (%s)\n", sym, last.Price, last.Time.Format("15:04")))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("• *%s*: waiting...\n", sym))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (s *Sentinel) scan() {
|
||||
s.mu.RLock()
|
||||
syms := make([]string, len(s.symbols))
|
||||
copy(syms, s.symbols)
|
||||
s.mu.RUnlock()
|
||||
for _, sym := range syms {
|
||||
s.check(sym)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sentinel) check(symbol string) {
|
||||
resp, err := s.http.Get(fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
|
||||
if err != nil { return }
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
s.logger.Debug("sentinel ticker non-200", "symbol", symbol, "status", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
body, err := safe.ReadAllLimited(resp.Body, 256*1024) // 256KB limit
|
||||
if err != nil { return }
|
||||
var t map[string]interface{}
|
||||
if err := json.Unmarshal(body, &t); err != nil { return }
|
||||
|
||||
price, _ := strconv.ParseFloat(fmt.Sprint(t["lastPrice"]), 64)
|
||||
vol, _ := strconv.ParseFloat(fmt.Sprint(t["quoteVolume"]), 64)
|
||||
chg, _ := strconv.ParseFloat(fmt.Sprint(t["priceChangePercent"]), 64)
|
||||
|
||||
pt := pricePt{Price: price, Volume: vol, Time: time.Now()}
|
||||
s.mu.Lock()
|
||||
h := s.history[symbol]
|
||||
h = append(h, pt)
|
||||
if len(h) > 60 { h = h[len(h)-60:] }
|
||||
s.history[symbol] = h
|
||||
s.mu.Unlock()
|
||||
|
||||
if len(h) < 5 { return }
|
||||
|
||||
// Price breakout (>3% in 5 min)
|
||||
old := h[len(h)-5]
|
||||
pct := ((price - old.Price) / old.Price) * 100
|
||||
if math.Abs(pct) >= 3.0 {
|
||||
sev := "warning"
|
||||
if math.Abs(pct) >= 6.0 { sev = "critical" }
|
||||
dir := "📈 拉升"
|
||||
if pct < 0 { dir = "📉 下跌" }
|
||||
s.emit(Signal{Type: SignalPriceBreakout, Symbol: symbol, Severity: sev,
|
||||
Title: fmt.Sprintf("%s %s %.1f%%", symbol, dir, math.Abs(pct)),
|
||||
Detail: fmt.Sprintf("5min: $%.2f → $%.2f (24h: %.1f%%)", old.Price, price, chg),
|
||||
Price: price, Change: pct})
|
||||
}
|
||||
|
||||
// Volume spike (>3x avg)
|
||||
if len(h) >= 10 {
|
||||
var avg float64
|
||||
for i := 0; i < len(h)-1; i++ { avg += h[i].Volume }
|
||||
avg /= float64(len(h) - 1)
|
||||
if avg > 0 && vol > avg*3 {
|
||||
s.emit(Signal{Type: SignalVolumeSpike, Symbol: symbol, Severity: "warning",
|
||||
Title: fmt.Sprintf("%s 成交量异常 %.1fx", symbol, vol/avg),
|
||||
Detail: fmt.Sprintf("Price: $%.2f (24h: %.1f%%)", price, chg),
|
||||
Price: price, Change: chg})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sentinel) emit(sig Signal) {
|
||||
s.logger.Info("signal", "type", sig.Type, "symbol", sig.Symbol, "title", sig.Title)
|
||||
if s.onSignal != nil { s.onSignal(sig) }
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package agent
|
||||
|
||||
func skillCatalogPrompt(lang string) string {
|
||||
if lang == "zh" {
|
||||
return `## 多轮与 Skill-First 工作模式
|
||||
- 对于高频已知任务,优先按 skill 执行,不要每次从零规划
|
||||
- 如果用户仍在同一任务里,继续当前 flow,不要重新路由
|
||||
- 只追问继续执行所需的最少必要字段,不要让用户重复已确认信息
|
||||
- 高风险动作(删除、启动实盘、停止运行中 trader、覆盖关键配置)必须单独确认
|
||||
- 对诊断类问题,优先做“问题归类 -> 可能原因 -> 核查项 -> 下一步建议”
|
||||
|
||||
## 当前重点技能
|
||||
### 1. 模型配置与诊断
|
||||
- ` + "`skill_model_api_setup`" + `:用户问某个大模型的 API key 去哪申请、base URL 怎么填、model name 怎么填时,给步骤化指导
|
||||
- ` + "`skill_model_config_diagnosis`" + `:当用户遇到模型配置失败、调用失败、保存后不可用时,优先检查:
|
||||
1. 是否已启用模型
|
||||
2. API Key 是否为空
|
||||
3. custom_api_url 是否为合法 HTTPS 地址
|
||||
4. custom_model_name 是否为空或填错
|
||||
5. 保存后是否需要重新加载 trader
|
||||
- 已知事实:
|
||||
- 系统会拒绝非 HTTPS 的 custom_api_url
|
||||
- 已启用模型如果缺少 API Key 或 custom_api_url,会导致 agent 不可用
|
||||
|
||||
### 2. 交易所配置与诊断
|
||||
- ` + "`skill_exchange_api_setup`" + `:指导用户创建交易所 API,明确需要哪些权限、哪些权限不要开、哪些交易所需要额外字段
|
||||
- ` + "`skill_exchange_api_diagnosis`" + `:用户遇到 invalid signature、timestamp、permission denied、IP not allowed 时,优先排查:
|
||||
1. 系统时间是否同步
|
||||
2. API Key / Secret 是否填反或过期
|
||||
3. IP 白名单是否包含服务器 IP
|
||||
4. 是否启用了合约/交易权限
|
||||
5. OKX 是否遗漏 passphrase
|
||||
- 已知事实:
|
||||
- OKX 除 API Key 和 Secret 外还需要 passphrase
|
||||
- invalid signature / timestamp 常见根因是时间不同步或密钥不匹配
|
||||
|
||||
### 3. Trader 启动与运行诊断
|
||||
- ` + "`skill_trader_start_diagnosis`" + `:当用户说 trader 启动不了、启动后不交易、没有持仓、没有决策时,优先排查:
|
||||
1. 是否存在可用且启用的模型配置
|
||||
2. 是否存在可用且启用的交易所配置
|
||||
3. trader 绑定的 strategy / exchange / model 是否齐全
|
||||
4. 账户余额和权限是否满足下单要求
|
||||
5. AI 是否一直返回 wait / hold
|
||||
- 如果用户问“为什么没有开仓”,要明确区分:
|
||||
- 系统没启动
|
||||
- 启动了但 AI 决策为 wait
|
||||
- 有信号但下单失败
|
||||
|
||||
### 4. 交易行为异常诊断
|
||||
- ` + "`skill_order_execution_diagnosis`" + `:当用户问仓位开不出来、只开单边、杠杆报错时,优先排查:
|
||||
1. 是否为交易所模式问题(例如 Binance One-way / Hedge Mode)
|
||||
2. 是否为子账户杠杆限制
|
||||
3. 是否为合约权限或 symbol 不可交易
|
||||
4. 是否为余额不足或保证金占用过高
|
||||
- 已知事实:
|
||||
- Binance 若不是 Hedge Mode,可能出现 position side mismatch 或只开单边
|
||||
- 某些子账户杠杆受限,超过限制会直接报错
|
||||
|
||||
### 5. 策略与提示词诊断
|
||||
- ` + "`skill_strategy_diagnosis`" + `:当用户说策略没生效、提示词不对、预览和实际不一致时,优先建议:
|
||||
1. 查看当前 strategy 配置
|
||||
2. 区分策略模板本身和 trader 上的 custom prompt
|
||||
3. 必要时预览 prompt 或读取当前保存值后再判断
|
||||
|
||||
## 回答格式要求
|
||||
- 诊断类问题尽量按“现象 / 原因 / 先检查什么 / 怎么修复”回答
|
||||
- 配置指导类问题尽量按步骤回答
|
||||
- 如果已有工具能验证当前状态,先查再下结论
|
||||
- 如果结论是推测,必须明确说是“更可能”或“优先怀疑”`
|
||||
}
|
||||
|
||||
return `## Multi-turn and Skill-First Operating Mode
|
||||
- For high-frequency known tasks, prefer stable skills instead of replanning from scratch
|
||||
- If the user is still in the same task, continue the active flow
|
||||
- Ask only for the minimum missing fields required to proceed
|
||||
- Require explicit confirmation for destructive or financially sensitive actions
|
||||
- For diagnostic requests, use: issue class -> likely causes -> checks -> next steps
|
||||
|
||||
## Priority Skills
|
||||
- skill_model_api_setup / skill_model_config_diagnosis
|
||||
- skill_exchange_api_setup / skill_exchange_api_diagnosis
|
||||
- skill_trader_start_diagnosis
|
||||
- skill_order_execution_diagnosis
|
||||
- skill_strategy_diagnosis
|
||||
|
||||
Known facts:
|
||||
- custom_api_url must be a valid HTTPS URL
|
||||
- OKX requires passphrase in addition to API key and secret
|
||||
- invalid signature / timestamp often means clock skew or mismatched credentials
|
||||
- missing enabled model or exchange config can block trader startup
|
||||
- Binance position-side issues are often caused by One-way Mode vs Hedge Mode
|
||||
|
||||
Response style:
|
||||
- Diagnostics: symptom -> cause -> checks -> fix
|
||||
- Setup guidance: step-by-step
|
||||
- Verify with tools when possible before concluding`
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSkillCatalogPromptZHIncludesDiagnosisSkills(t *testing.T) {
|
||||
got := skillCatalogPrompt("zh")
|
||||
for _, want := range []string{
|
||||
"多轮与 Skill-First 工作模式",
|
||||
"skill_model_config_diagnosis",
|
||||
"skill_exchange_api_diagnosis",
|
||||
"skill_trader_start_diagnosis",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("skillCatalogPrompt(zh) missing %q\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptIncludesSkillCatalog(t *testing.T) {
|
||||
a := New(nil, nil, DefaultConfig(), slog.Default())
|
||||
got := a.buildSystemPrompt("zh")
|
||||
for _, want := range []string{
|
||||
"多轮与 Skill-First 工作模式",
|
||||
"skill_exchange_api_setup",
|
||||
"skill_order_execution_diagnosis",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("buildSystemPrompt(zh) missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "strings"
|
||||
|
||||
type SkillDAG struct {
|
||||
SkillName string
|
||||
Action string
|
||||
Steps []SkillDAGStep
|
||||
}
|
||||
|
||||
type SkillDAGStep struct {
|
||||
ID string
|
||||
Kind string
|
||||
RequiredFields []string
|
||||
OptionalFields []string
|
||||
Next []string
|
||||
Terminal bool
|
||||
}
|
||||
|
||||
var skillDAGRegistry = buildSkillDAGRegistry()
|
||||
|
||||
func buildSkillDAGRegistry() map[string]SkillDAG {
|
||||
dags := []SkillDAG{
|
||||
{
|
||||
SkillName: "trader_management",
|
||||
Action: "create",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"resolve_exchange"}},
|
||||
{ID: "resolve_exchange", Kind: "collect_slot", RequiredFields: []string{"exchange_id"}, OptionalFields: []string{"exchange_name"}, Next: []string{"resolve_model"}},
|
||||
{ID: "resolve_model", Kind: "collect_slot", RequiredFields: []string{"model_id"}, OptionalFields: []string{"model_name"}, Next: []string{"resolve_strategy"}},
|
||||
{ID: "resolve_strategy", Kind: "collect_slot", RequiredFields: []string{"strategy_id"}, OptionalFields: []string{"strategy_name"}, Next: []string{"maybe_confirm_start"}},
|
||||
{ID: "maybe_confirm_start", Kind: "branch", OptionalFields: []string{"auto_start"}, Next: []string{"await_start_confirmation", "execute_create_only"}},
|
||||
{ID: "await_start_confirmation", Kind: "confirm", RequiredFields: []string{"auto_start"}, Next: []string{"execute_create_and_start", "execute_create_only"}},
|
||||
{ID: "execute_create_only", Kind: "execute", RequiredFields: []string{"name", "exchange_id", "model_id", "strategy_id"}, Terminal: true},
|
||||
{ID: "execute_create_and_start", Kind: "execute", RequiredFields: []string{"name", "exchange_id", "model_id", "strategy_id"}, OptionalFields: []string{"auto_start"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "trader_management",
|
||||
Action: "update_name",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}},
|
||||
{ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_update"}},
|
||||
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "trader_management",
|
||||
Action: "update_bindings",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_bindings"}},
|
||||
{ID: "collect_bindings", Kind: "collect_slot", RequiredFields: []string{"binding_update"}, OptionalFields: []string{"ai_model_id", "exchange_id", "strategy_id"}, Next: []string{"execute_update"}},
|
||||
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "binding_update"}, OptionalFields: []string{"ai_model_id", "exchange_id", "strategy_id"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "trader_management",
|
||||
Action: "start",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
|
||||
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_start"}},
|
||||
{ID: "execute_start", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "trader_management",
|
||||
Action: "stop",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
|
||||
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_stop"}},
|
||||
{ID: "execute_stop", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "trader_management",
|
||||
Action: "delete",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
|
||||
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
|
||||
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "strategy_management",
|
||||
Action: "create",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_name", Kind: "collect_slot", RequiredFields: []string{"name"}, OptionalFields: []string{"lang", "description", "config"}, Next: []string{"execute_create"}},
|
||||
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"name"}, OptionalFields: []string{"lang", "description", "config"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "strategy_management",
|
||||
Action: "update_name",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}},
|
||||
{ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_update"}},
|
||||
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "strategy_management",
|
||||
Action: "update_prompt",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_prompt"}},
|
||||
{ID: "collect_prompt", Kind: "collect_slot", RequiredFields: []string{"prompt"}, Next: []string{"load_config"}},
|
||||
{ID: "load_config", Kind: "load_state", RequiredFields: []string{"target_ref"}, Next: []string{"execute_update"}},
|
||||
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "prompt"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "strategy_management",
|
||||
Action: "update_config",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"resolve_config_field"}},
|
||||
{ID: "resolve_config_field", Kind: "collect_slot", RequiredFields: []string{"config_field"}, Next: []string{"resolve_config_value"}},
|
||||
{ID: "resolve_config_value", Kind: "collect_slot", RequiredFields: []string{"config_value"}, Next: []string{"load_config"}},
|
||||
{ID: "load_config", Kind: "load_state", RequiredFields: []string{"target_ref"}, Next: []string{"apply_field_update"}},
|
||||
{ID: "apply_field_update", Kind: "transform", RequiredFields: []string{"config_field", "config_value"}, Next: []string{"execute_update"}},
|
||||
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "config_field", "config_value"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "strategy_management",
|
||||
Action: "duplicate",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_name"}},
|
||||
{ID: "collect_name", Kind: "collect_slot", RequiredFields: []string{"name"}, Next: []string{"execute_duplicate"}},
|
||||
{ID: "execute_duplicate", Kind: "execute", RequiredFields: []string{"target_ref", "name"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "strategy_management",
|
||||
Action: "activate",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"execute_activate"}},
|
||||
{ID: "execute_activate", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "strategy_management",
|
||||
Action: "delete",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
|
||||
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
|
||||
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "model_management",
|
||||
Action: "create",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_provider", Kind: "collect_slot", RequiredFields: []string{"provider"}, Next: []string{"collect_optional_fields"}},
|
||||
{ID: "collect_optional_fields", Kind: "collect_slot", OptionalFields: []string{"name", "custom_api_url", "custom_model_name"}, Next: []string{"execute_create"}},
|
||||
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"provider"}, OptionalFields: []string{"name", "custom_api_url", "custom_model_name"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "model_management",
|
||||
Action: "update_status",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_enabled"}},
|
||||
{ID: "collect_enabled", Kind: "collect_slot", RequiredFields: []string{"enabled"}, Next: []string{"execute_update"}},
|
||||
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "enabled"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "model_management",
|
||||
Action: "update_endpoint",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_custom_api_url"}},
|
||||
{ID: "collect_custom_api_url", Kind: "collect_slot", RequiredFields: []string{"custom_api_url"}, Next: []string{"execute_update"}},
|
||||
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "custom_api_url"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "model_management",
|
||||
Action: "update_name",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_custom_model_name"}},
|
||||
{ID: "collect_custom_model_name", Kind: "collect_slot", RequiredFields: []string{"custom_model_name"}, Next: []string{"execute_update"}},
|
||||
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "custom_model_name"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "model_management",
|
||||
Action: "delete",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
|
||||
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
|
||||
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "exchange_management",
|
||||
Action: "create",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_exchange_type", Kind: "collect_slot", RequiredFields: []string{"exchange_type"}, Next: []string{"collect_account_name"}},
|
||||
{ID: "collect_account_name", Kind: "collect_slot", OptionalFields: []string{"account_name"}, Next: []string{"execute_create"}},
|
||||
{ID: "execute_create", Kind: "execute", RequiredFields: []string{"exchange_type"}, OptionalFields: []string{"account_name"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "exchange_management",
|
||||
Action: "update_name",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_account_name"}},
|
||||
{ID: "collect_account_name", Kind: "collect_slot", RequiredFields: []string{"account_name"}, Next: []string{"execute_update"}},
|
||||
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "account_name"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "exchange_management",
|
||||
Action: "update_status",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"collect_enabled"}},
|
||||
{ID: "collect_enabled", Kind: "collect_slot", RequiredFields: []string{"enabled"}, Next: []string{"execute_update"}},
|
||||
{ID: "execute_update", Kind: "execute", RequiredFields: []string{"target_ref", "enabled"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
SkillName: "exchange_management",
|
||||
Action: "delete",
|
||||
Steps: []SkillDAGStep{
|
||||
{ID: "resolve_target", Kind: "resolve_target", RequiredFields: []string{"target_ref"}, Next: []string{"await_confirmation"}},
|
||||
{ID: "await_confirmation", Kind: "confirm", RequiredFields: []string{"target_ref"}, Next: []string{"execute_delete"}},
|
||||
{ID: "execute_delete", Kind: "execute", RequiredFields: []string{"target_ref"}, Terminal: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
registry := make(map[string]SkillDAG, len(dags))
|
||||
for _, dag := range dags {
|
||||
dag = normalizeSkillDAG(dag)
|
||||
if dag.SkillName == "" || dag.Action == "" {
|
||||
continue
|
||||
}
|
||||
registry[skillDAGKey(dag.SkillName, dag.Action)] = dag
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
func normalizeSkillDAG(dag SkillDAG) SkillDAG {
|
||||
dag.SkillName = strings.TrimSpace(dag.SkillName)
|
||||
dag.Action = strings.TrimSpace(dag.Action)
|
||||
steps := make([]SkillDAGStep, 0, len(dag.Steps))
|
||||
for _, step := range dag.Steps {
|
||||
step.ID = strings.TrimSpace(step.ID)
|
||||
step.Kind = strings.TrimSpace(step.Kind)
|
||||
step.RequiredFields = cleanStringList(step.RequiredFields)
|
||||
step.OptionalFields = cleanStringList(step.OptionalFields)
|
||||
step.Next = cleanStringList(step.Next)
|
||||
if step.ID == "" {
|
||||
continue
|
||||
}
|
||||
steps = append(steps, step)
|
||||
}
|
||||
dag.Steps = steps
|
||||
return dag
|
||||
}
|
||||
|
||||
func skillDAGKey(skillName, action string) string {
|
||||
return strings.TrimSpace(skillName) + ":" + strings.TrimSpace(action)
|
||||
}
|
||||
|
||||
func getSkillDAG(skillName, action string) (SkillDAG, bool) {
|
||||
dag, ok := skillDAGRegistry[skillDAGKey(skillName, action)]
|
||||
return dag, ok
|
||||
}
|
||||
|
||||
func listSkillDAGs() []SkillDAG {
|
||||
out := make([]SkillDAG, 0, len(skillDAGRegistry))
|
||||
for _, dag := range skillDAGRegistry {
|
||||
out = append(out, dag)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package agent
|
||||
|
||||
const skillDAGStepField = "_dag_step"
|
||||
|
||||
func currentSkillDAGStep(session skillSession) (SkillDAGStep, bool) {
|
||||
dag, ok := getSkillDAG(session.Name, session.Action)
|
||||
if !ok || len(dag.Steps) == 0 {
|
||||
return SkillDAGStep{}, false
|
||||
}
|
||||
stepID := fieldValue(session, skillDAGStepField)
|
||||
if stepID == "" {
|
||||
return dag.Steps[0], true
|
||||
}
|
||||
for _, step := range dag.Steps {
|
||||
if step.ID == stepID {
|
||||
return step, true
|
||||
}
|
||||
}
|
||||
return dag.Steps[0], true
|
||||
}
|
||||
|
||||
func setSkillDAGStep(session *skillSession, stepID string) {
|
||||
ensureSkillFields(session)
|
||||
if stepID == "" {
|
||||
delete(session.Fields, skillDAGStepField)
|
||||
return
|
||||
}
|
||||
session.Fields[skillDAGStepField] = stepID
|
||||
}
|
||||
|
||||
func clearSkillDAGStep(session *skillSession) {
|
||||
if session == nil || session.Fields == nil {
|
||||
return
|
||||
}
|
||||
delete(session.Fields, skillDAGStepField)
|
||||
}
|
||||
|
||||
func advanceSkillDAGStep(session *skillSession, currentStepID string) {
|
||||
dag, ok := getSkillDAG(session.Name, session.Action)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, step := range dag.Steps {
|
||||
if step.ID != currentStepID || len(step.Next) == 0 {
|
||||
continue
|
||||
}
|
||||
setSkillDAGStep(session, step.Next[0])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCurrentSkillDAGStepDefaultsToFirstStep(t *testing.T) {
|
||||
session := skillSession{Name: "strategy_management", Action: "update_config"}
|
||||
step, ok := currentSkillDAGStep(session)
|
||||
if !ok {
|
||||
t.Fatal("expected dag step")
|
||||
}
|
||||
if step.ID != "resolve_target" {
|
||||
t.Fatalf("expected first step resolve_target, got %s", step.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvanceSkillDAGStepMovesToNextStep(t *testing.T) {
|
||||
session := skillSession{Name: "strategy_management", Action: "update_config"}
|
||||
setSkillDAGStep(&session, "resolve_config_field")
|
||||
advanceSkillDAGStep(&session, "resolve_config_field")
|
||||
step, ok := currentSkillDAGStep(session)
|
||||
if !ok {
|
||||
t.Fatal("expected dag step")
|
||||
}
|
||||
if step.ID != "resolve_config_value" {
|
||||
t.Fatalf("expected resolve_config_value, got %s", step.ID)
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGetSkillDAGForStructuredActions(t *testing.T) {
|
||||
tests := []struct {
|
||||
skill string
|
||||
action string
|
||||
}{
|
||||
{skill: "trader_management", action: "create"},
|
||||
{skill: "trader_management", action: "update_bindings"},
|
||||
{skill: "strategy_management", action: "update_config"},
|
||||
{skill: "strategy_management", action: "update_prompt"},
|
||||
{skill: "model_management", action: "update_status"},
|
||||
{skill: "exchange_management", action: "update_name"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
dag, ok := getSkillDAG(tt.skill, tt.action)
|
||||
if !ok {
|
||||
t.Fatalf("expected DAG for %s/%s", tt.skill, tt.action)
|
||||
}
|
||||
if dag.SkillName != tt.skill || dag.Action != tt.action {
|
||||
t.Fatalf("unexpected dag identity: %+v", dag)
|
||||
}
|
||||
if len(dag.Steps) == 0 {
|
||||
t.Fatalf("expected DAG steps for %s/%s", tt.skill, tt.action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredDAGsHaveTerminalStep(t *testing.T) {
|
||||
for _, dag := range listSkillDAGs() {
|
||||
hasTerminal := false
|
||||
for _, step := range dag.Steps {
|
||||
if step.Terminal {
|
||||
hasTerminal = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTerminal {
|
||||
t.Fatalf("expected terminal step for %s/%s", dag.SkillName, dag.Action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyUpdateConfigDAGMatchesCurrentAtomicFlow(t *testing.T) {
|
||||
dag, ok := getSkillDAG("strategy_management", "update_config")
|
||||
if !ok {
|
||||
t.Fatal("missing strategy update_config dag")
|
||||
}
|
||||
if len(dag.Steps) != 6 {
|
||||
t.Fatalf("expected 6 steps, got %d", len(dag.Steps))
|
||||
}
|
||||
if dag.Steps[0].ID != "resolve_target" {
|
||||
t.Fatalf("expected first step resolve_target, got %s", dag.Steps[0].ID)
|
||||
}
|
||||
if dag.Steps[1].ID != "resolve_config_field" {
|
||||
t.Fatalf("expected second step resolve_config_field, got %s", dag.Steps[1].ID)
|
||||
}
|
||||
if dag.Steps[2].ID != "resolve_config_value" {
|
||||
t.Fatalf("expected third step resolve_config_value, got %s", dag.Steps[2].ID)
|
||||
}
|
||||
if dag.Steps[5].ID != "execute_update" || !dag.Steps[5].Terminal {
|
||||
t.Fatalf("expected final terminal execute step, got %+v", dag.Steps[5])
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,828 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
func TestCreateTraderSkillCollectsMissingFieldsAndCreatesTrader(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
modelResp := a.toolManageModelConfig("user-1", `{
|
||||
"action":"create",
|
||||
"provider":"deepseek",
|
||||
"enabled":true,
|
||||
"api_key":"sk-test",
|
||||
"custom_api_url":"https://api.deepseek.com/v1",
|
||||
"custom_model_name":"deepseek-chat"
|
||||
}`)
|
||||
if strings.Contains(modelResp, `"error"`) {
|
||||
t.Fatalf("failed to create model: %s", modelResp)
|
||||
}
|
||||
exchangeResp := a.toolManageExchangeConfig("user-1", `{
|
||||
"action":"create",
|
||||
"exchange_type":"okx",
|
||||
"account_name":"主账户",
|
||||
"enabled":true
|
||||
}`)
|
||||
if strings.Contains(exchangeResp, `"error"`) {
|
||||
t.Fatalf("failed to create exchange: %s", exchangeResp)
|
||||
}
|
||||
strategyResp := a.toolManageStrategy("user-1", `{
|
||||
"action":"create",
|
||||
"name":"趋势策略",
|
||||
"lang":"zh"
|
||||
}`)
|
||||
if strings.Contains(strategyResp, `"error"`) {
|
||||
t.Fatalf("failed to create strategy: %s", strategyResp)
|
||||
}
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 1, "zh", "帮我创建一个交易员")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "还缺这些信息") || !strings.Contains(resp, "名称") {
|
||||
t.Fatalf("expected missing-field prompt, got %q", resp)
|
||||
}
|
||||
|
||||
resp, err = a.thinkAndAct(context.Background(), "user-1", 1, "zh", "叫 波段一号")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() second turn error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已创建交易员") || !strings.Contains(resp, "波段一号") {
|
||||
t.Fatalf("expected trader creation confirmation, got %q", resp)
|
||||
}
|
||||
|
||||
listResp := a.toolListTraders("user-1")
|
||||
if !strings.Contains(listResp, "波段一号") {
|
||||
t.Fatalf("expected created trader in list, got %s", listResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTraderSkillReportsAllMissingPrerequisitesAtOnce(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 11, "zh", "帮我创建一个交易员")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{"名称", "交易所", "模型", "策略"} {
|
||||
if !strings.Contains(resp, want) {
|
||||
t.Fatalf("expected response to mention %q, got %q", want, resp)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"当前还没有可用交易所配置", "当前还没有可用模型配置", "当前还没有可用策略"} {
|
||||
if !strings.Contains(resp, want) {
|
||||
t.Fatalf("expected response to mention prerequisite %q, got %q", want, resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveSkillSessionYieldsToNewTopic(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
_ = a.toolManageStrategy("user-1", `{
|
||||
"action":"create",
|
||||
"name":"测试策略",
|
||||
"lang":"zh"
|
||||
}`)
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 13, "zh", "帮我创建一个交易员")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "还缺这些信息") {
|
||||
t.Fatalf("expected trader creation flow prompt, got %q", resp)
|
||||
}
|
||||
|
||||
resp, err = a.thinkAndAct(context.Background(), "user-1", 13, "zh", "列出我当前的策略")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() interrupt error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "当前策略") || !strings.Contains(resp, "测试策略") {
|
||||
t.Fatalf("expected new topic to be handled, got %q", resp)
|
||||
}
|
||||
if a.hasActiveSkillSession(13) {
|
||||
t.Fatal("expected skill session to be cleared after interruption")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTraderSkillRequestsStartConfirmation(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
_ = a.toolManageModelConfig("user-1", `{
|
||||
"action":"create",
|
||||
"provider":"openai",
|
||||
"enabled":true,
|
||||
"api_key":"sk-test",
|
||||
"custom_api_url":"https://api.openai.com/v1",
|
||||
"custom_model_name":"gpt-5"
|
||||
}`)
|
||||
_ = a.toolManageExchangeConfig("user-1", `{
|
||||
"action":"create",
|
||||
"exchange_type":"binance",
|
||||
"account_name":"Main",
|
||||
"enabled":true
|
||||
}`)
|
||||
_ = a.toolManageStrategy("user-1", `{
|
||||
"action":"create",
|
||||
"name":"保守策略",
|
||||
"lang":"zh"
|
||||
}`)
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 2, "zh", "创建一个叫“实盘一号”的交易员并启动")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "高风险动作") || !strings.Contains(resp, "确认") {
|
||||
t.Fatalf("expected start confirmation prompt, got %q", resp)
|
||||
}
|
||||
|
||||
resp, err = a.thinkAndAct(context.Background(), "user-1", 2, "zh", "先不用")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() confirmation error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已创建交易员") || strings.Contains(resp, "已创建并启动") {
|
||||
t.Fatalf("expected create-without-start response, got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelDiagnosisSkillHandledWithoutAIClient(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 3, "zh", "为什么我的模型配置失败了")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "模型配置") {
|
||||
t.Fatalf("expected model diagnosis response, got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeDiagnosisSkillHandledWithoutAIClient(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 4, "zh", "交易所 API 报 invalid signature 怎么办")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "invalid signature") && !strings.Contains(resp, "签名") {
|
||||
t.Fatalf("expected exchange diagnosis response, got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeManagementCreateAndQuerySkill(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 5, "zh", "帮我创建一个 OKX 交易所配置")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已创建交易所配置") {
|
||||
t.Fatalf("expected exchange create response, got %q", resp)
|
||||
}
|
||||
|
||||
resp, err = a.thinkAndAct(context.Background(), "user-1", 5, "zh", "列出我的交易所配置")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() query error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "当前交易所配置") && !strings.Contains(resp, "Default") {
|
||||
t.Fatalf("expected exchange query response, got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelManagementCreateSkill(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 6, "zh", "帮我创建一个 DeepSeek 模型配置")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已创建模型配置") {
|
||||
t.Fatalf("expected model create response, got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyManagementCreateAndActivateSkill(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 7, "zh", "创建一个叫“趋势策略B”的策略")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() create error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已创建策略") {
|
||||
t.Fatalf("expected strategy create response, got %q", resp)
|
||||
}
|
||||
|
||||
resp, err = a.thinkAndAct(context.Background(), "user-1", 7, "zh", "激活趋势策略B")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() activate error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已激活策略") {
|
||||
t.Fatalf("expected strategy activate response, got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyManagementQueryCanExplainStrategyDetails(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 12, "zh", "创建一个叫“激进的”的策略")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() create error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已创建策略") {
|
||||
t.Fatalf("expected strategy create response, got %q", resp)
|
||||
}
|
||||
|
||||
resp, err = a.thinkAndAct(context.Background(), "user-1", 12, "zh", "这个策略里面的参数和prompt分别是什么样的")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() detail query error = %v", err)
|
||||
}
|
||||
for _, want := range []string{"策略“激进的”概览", "K线周期", "仓位风险", "Prompt"} {
|
||||
if !strings.Contains(resp, want) {
|
||||
t.Fatalf("expected response to mention %q, got %q", want, resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraderManagementQueryAndDiagnosisSkill(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
modelResp := a.toolManageModelConfig("user-1", `{
|
||||
"action":"create",
|
||||
"provider":"openai",
|
||||
"enabled":true,
|
||||
"api_key":"sk-test",
|
||||
"custom_api_url":"https://api.openai.com/v1",
|
||||
"custom_model_name":"gpt-5"
|
||||
}`)
|
||||
var modelCreated struct {
|
||||
Model safeModelToolConfig `json:"model"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(modelResp), &modelCreated); err != nil {
|
||||
t.Fatalf("unmarshal model response: %v", err)
|
||||
}
|
||||
|
||||
exchangeResp := a.toolManageExchangeConfig("user-1", `{
|
||||
"action":"create",
|
||||
"exchange_type":"binance",
|
||||
"account_name":"Main",
|
||||
"enabled":true
|
||||
}`)
|
||||
var exchangeCreated struct {
|
||||
Exchange safeExchangeToolConfig `json:"exchange"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(exchangeResp), &exchangeCreated); err != nil {
|
||||
t.Fatalf("unmarshal exchange response: %v", err)
|
||||
}
|
||||
_ = a.toolManageStrategy("user-1", `{
|
||||
"action":"create",
|
||||
"name":"测试策略",
|
||||
"lang":"zh"
|
||||
}`)
|
||||
_ = a.toolManageTrader("user-1", `{
|
||||
"action":"create",
|
||||
"name":"测试交易员",
|
||||
"ai_model_id":"`+modelCreated.Model.ID+`",
|
||||
"exchange_id":"`+exchangeCreated.Exchange.ID+`",
|
||||
"strategy_id":""
|
||||
}`)
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 8, "zh", "查看我的交易员")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() query error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "当前交易员") && !strings.Contains(resp, "测试交易员") {
|
||||
t.Fatalf("expected trader query response, got %q", resp)
|
||||
}
|
||||
|
||||
resp, err = a.thinkAndAct(context.Background(), "user-1", 8, "zh", "为什么我的交易员不交易")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() diagnosis error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "交易员运行诊断") {
|
||||
t.Fatalf("expected trader diagnosis response, got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeManagementAtomicUpdates(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
createResp := a.toolManageExchangeConfig("user-1", `{
|
||||
"action":"create",
|
||||
"exchange_type":"okx",
|
||||
"account_name":"主账户",
|
||||
"enabled":true
|
||||
}`)
|
||||
var created struct {
|
||||
Exchange safeExchangeToolConfig `json:"exchange"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
|
||||
t.Fatalf("unmarshal exchange response: %v", err)
|
||||
}
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 14, "zh", "更新交易所,把主账户改名为备用账户")
|
||||
if err != nil {
|
||||
t.Fatalf("rename exchange error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已更新交易所配置") {
|
||||
t.Fatalf("expected exchange update response, got %q", resp)
|
||||
}
|
||||
|
||||
raw := a.toolGetExchangeConfigs("user-1")
|
||||
if !strings.Contains(raw, "备用账户") {
|
||||
t.Fatalf("expected renamed exchange in list, got %s", raw)
|
||||
}
|
||||
|
||||
resp, err = a.thinkAndAct(context.Background(), "user-1", 14, "zh", "禁用这个交易所配置")
|
||||
if err != nil {
|
||||
t.Fatalf("disable exchange error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已更新交易所配置") {
|
||||
t.Fatalf("expected exchange status update response, got %q", resp)
|
||||
}
|
||||
|
||||
raw = a.toolGetExchangeConfigs("user-1")
|
||||
if strings.Contains(raw, `"enabled":true`) && strings.Contains(raw, "备用账户") {
|
||||
t.Fatalf("expected exchange to be disabled, got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelManagementAtomicUpdates(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
createResp := a.toolManageModelConfig("user-1", `{
|
||||
"action":"create",
|
||||
"provider":"deepseek",
|
||||
"enabled":true,
|
||||
"custom_api_url":"https://api.deepseek.com/v1",
|
||||
"custom_model_name":"deepseek-chat"
|
||||
}`)
|
||||
var created struct {
|
||||
Model safeModelToolConfig `json:"model"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(createResp), &created); err != nil {
|
||||
t.Fatalf("unmarshal model response: %v", err)
|
||||
}
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 15, "zh", "更新模型,把模型名称改成 deepseek-reasoner")
|
||||
if err != nil {
|
||||
t.Fatalf("rename model error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已更新模型配置") {
|
||||
t.Fatalf("expected model update response, got %q", resp)
|
||||
}
|
||||
|
||||
resp, err = a.thinkAndAct(context.Background(), "user-1", 15, "zh", "更新模型,把接口地址改成 https://api.deepseek.com/beta")
|
||||
if err != nil {
|
||||
t.Fatalf("update model endpoint error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已更新模型配置") {
|
||||
t.Fatalf("expected model endpoint update response, got %q", resp)
|
||||
}
|
||||
|
||||
resp, err = a.thinkAndAct(context.Background(), "user-1", 15, "zh", "禁用这个模型配置")
|
||||
if err != nil {
|
||||
t.Fatalf("disable model error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已更新模型配置") {
|
||||
t.Fatalf("expected model status update response, got %q", resp)
|
||||
}
|
||||
|
||||
raw := a.toolGetModelConfigs("user-1")
|
||||
if !strings.Contains(raw, "deepseek-reasoner") || !strings.Contains(raw, "https://api.deepseek.com/beta") {
|
||||
t.Fatalf("expected updated model fields, got %s", raw)
|
||||
}
|
||||
if strings.Contains(raw, `"enabled":true`) && strings.Contains(raw, created.Model.ID) {
|
||||
t.Fatalf("expected model to be disabled, got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyManagementAtomicUpdates(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 16, "zh", "创建一个叫“激进策略C”的策略")
|
||||
if err != nil {
|
||||
t.Fatalf("create strategy error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已创建策略") {
|
||||
t.Fatalf("expected strategy create response, got %q", resp)
|
||||
}
|
||||
|
||||
resp, err = a.thinkAndAct(context.Background(), "user-1", 16, "zh", "更新这个策略的prompt,把提示词改成“优先观察BTC和ETH,信号不一致时不要开仓”")
|
||||
if err != nil {
|
||||
t.Fatalf("update strategy prompt error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已更新策略 prompt") {
|
||||
t.Fatalf("expected strategy prompt update response, got %q", resp)
|
||||
}
|
||||
|
||||
resp, err = a.thinkAndAct(context.Background(), "user-1", 16, "zh", "更新这个策略参数,把最大持仓改成2,最低置信度改成80,主周期改成15m,并使用15m 1h 4h")
|
||||
if err != nil {
|
||||
t.Fatalf("update strategy config error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已更新策略参数") {
|
||||
t.Fatalf("expected strategy config update response, got %q", resp)
|
||||
}
|
||||
|
||||
listRaw := a.toolGetStrategies("user-1")
|
||||
if !strings.Contains(listRaw, "优先观察BTC和ETH") || !strings.Contains(listRaw, `"max_positions":2`) || !strings.Contains(listRaw, `"min_confidence":80`) || !strings.Contains(listRaw, `"primary_timeframe":"15m"`) {
|
||||
t.Fatalf("expected updated strategy config, got %s", listRaw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraderManagementAtomicBindingUpdate(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
modelOpenAI := a.toolManageModelConfig("user-1", `{
|
||||
"action":"create",
|
||||
"provider":"openai",
|
||||
"enabled":true,
|
||||
"custom_api_url":"https://api.openai.com/v1",
|
||||
"custom_model_name":"gpt-5-mini"
|
||||
}`)
|
||||
var openAI struct {
|
||||
Model safeModelToolConfig `json:"model"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(modelOpenAI), &openAI); err != nil {
|
||||
t.Fatalf("unmarshal openai model: %v", err)
|
||||
}
|
||||
modelDeepSeek := a.toolManageModelConfig("user-1", `{
|
||||
"action":"create",
|
||||
"provider":"deepseek",
|
||||
"enabled":true,
|
||||
"custom_api_url":"https://api.deepseek.com/v1",
|
||||
"custom_model_name":"deepseek-chat"
|
||||
}`)
|
||||
var deepSeek struct {
|
||||
Model safeModelToolConfig `json:"model"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(modelDeepSeek), &deepSeek); err != nil {
|
||||
t.Fatalf("unmarshal deepseek model: %v", err)
|
||||
}
|
||||
|
||||
exchangeBinance := a.toolManageExchangeConfig("user-1", `{
|
||||
"action":"create",
|
||||
"exchange_type":"binance",
|
||||
"account_name":"Binance 主账户",
|
||||
"enabled":true
|
||||
}`)
|
||||
var binance struct {
|
||||
Exchange safeExchangeToolConfig `json:"exchange"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(exchangeBinance), &binance); err != nil {
|
||||
t.Fatalf("unmarshal binance exchange: %v", err)
|
||||
}
|
||||
exchangeOKX := a.toolManageExchangeConfig("user-1", `{
|
||||
"action":"create",
|
||||
"exchange_type":"okx",
|
||||
"account_name":"OKX 主账户",
|
||||
"enabled":true
|
||||
}`)
|
||||
var okx struct {
|
||||
Exchange safeExchangeToolConfig `json:"exchange"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(exchangeOKX), &okx); err != nil {
|
||||
t.Fatalf("unmarshal okx exchange: %v", err)
|
||||
}
|
||||
|
||||
strategyA := a.toolManageStrategy("user-1", `{"action":"create","name":"策略A","lang":"zh"}`)
|
||||
var stA struct {
|
||||
Strategy safeStrategyToolConfig `json:"strategy"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strategyA), &stA); err != nil {
|
||||
t.Fatalf("unmarshal strategy A: %v", err)
|
||||
}
|
||||
strategyB := a.toolManageStrategy("user-1", `{"action":"create","name":"策略B","lang":"zh"}`)
|
||||
var stB struct {
|
||||
Strategy safeStrategyToolConfig `json:"strategy"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strategyB), &stB); err != nil {
|
||||
t.Fatalf("unmarshal strategy B: %v", err)
|
||||
}
|
||||
|
||||
createTrader := a.toolManageTrader("user-1", `{
|
||||
"action":"create",
|
||||
"name":"实盘一号",
|
||||
"ai_model_id":"`+openAI.Model.ID+`",
|
||||
"exchange_id":"`+binance.Exchange.ID+`",
|
||||
"strategy_id":"`+stA.Strategy.ID+`"
|
||||
}`)
|
||||
var trader struct {
|
||||
Trader safeTraderToolConfig `json:"trader"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(createTrader), &trader); err != nil {
|
||||
t.Fatalf("unmarshal trader: %v", err)
|
||||
}
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 17, "zh", "更新交易员绑定,把实盘一号换成 deepseek-chat、OKX 主账户 和 策略B")
|
||||
if err != nil {
|
||||
t.Fatalf("update trader bindings error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已更新交易员绑定") {
|
||||
t.Fatalf("expected trader binding update response, got %q", resp)
|
||||
}
|
||||
|
||||
listRaw := a.toolListTraders("user-1")
|
||||
if !strings.Contains(listRaw, deepSeek.Model.ID) || !strings.Contains(listRaw, okx.Exchange.ID) || !strings.Contains(listRaw, stB.Strategy.ID) {
|
||||
t.Fatalf("expected trader bindings to change, got %s", listRaw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyManagementDeleteAllUserStrategies(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
for _, name := range []string{"趋势策略A", "趋势策略B"} {
|
||||
resp := a.toolManageStrategy("user-1", `{
|
||||
"action":"create",
|
||||
"name":"`+name+`",
|
||||
"lang":"zh"
|
||||
}`)
|
||||
if strings.Contains(resp, `"error"`) {
|
||||
t.Fatalf("failed to create strategy %q: %s", name, resp)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 21, "zh", "现在把所有的策略全部删除")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() bulk delete start error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "确认") || !strings.Contains(resp, "全部自定义策略") {
|
||||
t.Fatalf("expected bulk delete confirmation, got %q", resp)
|
||||
}
|
||||
|
||||
resp, err = a.thinkAndAct(context.Background(), "user-1", 21, "zh", "确认")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() bulk delete confirm error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "成功删除 2 个") {
|
||||
t.Fatalf("expected bulk delete success summary, got %q", resp)
|
||||
}
|
||||
|
||||
listResp := a.toolGetStrategies("user-1")
|
||||
if strings.Contains(listResp, "趋势策略A") || strings.Contains(listResp, "趋势策略B") {
|
||||
t.Fatalf("expected created strategies to be deleted, got %s", listResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTraderSkillRejectsDisabledExchangeWithClearPrompt(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
|
||||
_ = a.toolManageModelConfig("user-1", `{
|
||||
"action":"create",
|
||||
"provider":"deepseek",
|
||||
"enabled":true,
|
||||
"api_key":"sk-test",
|
||||
"custom_api_url":"https://api.deepseek.com/v1",
|
||||
"custom_model_name":"deepseek-chat"
|
||||
}`)
|
||||
enabledExchange := a.toolManageExchangeConfig("user-1", `{
|
||||
"action":"create",
|
||||
"exchange_type":"okx",
|
||||
"account_name":"test",
|
||||
"enabled":true
|
||||
}`)
|
||||
if strings.Contains(enabledExchange, `"error"`) {
|
||||
t.Fatalf("failed to create enabled exchange: %s", enabledExchange)
|
||||
}
|
||||
anotherEnabledExchange := a.toolManageExchangeConfig("user-1", `{
|
||||
"action":"create",
|
||||
"exchange_type":"okx",
|
||||
"account_name":"lky",
|
||||
"enabled":true
|
||||
}`)
|
||||
if strings.Contains(anotherEnabledExchange, `"error"`) {
|
||||
t.Fatalf("failed to create second enabled exchange: %s", anotherEnabledExchange)
|
||||
}
|
||||
disabledExchange := a.toolManageExchangeConfig("user-1", `{
|
||||
"action":"create",
|
||||
"exchange_type":"okx",
|
||||
"account_name":"new",
|
||||
"enabled":false
|
||||
}`)
|
||||
if strings.Contains(disabledExchange, `"error"`) {
|
||||
t.Fatalf("failed to create disabled exchange: %s", disabledExchange)
|
||||
}
|
||||
_ = a.toolManageStrategy("user-1", `{"action":"create","name":"激进","lang":"zh"}`)
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 24, "zh", "给我创建一个trader")
|
||||
if err != nil {
|
||||
t.Fatalf("create trader start error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "new(已禁用)") {
|
||||
t.Fatalf("expected disabled exchange to be labelled, got %q", resp)
|
||||
}
|
||||
|
||||
resp, err = a.thinkAndAct(context.Background(), "user-1", 24, "zh", "名称叫test,交易所用new、策略用激进")
|
||||
if err != nil {
|
||||
t.Fatalf("disabled exchange selection error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "当前已禁用") {
|
||||
t.Fatalf("expected disabled exchange warning, got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelReplyExitsExchangeUpdateFlow(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
_ = a.toolManageModelConfig("user-1", `{
|
||||
"action":"create",
|
||||
"provider":"deepseek",
|
||||
"enabled":true,
|
||||
"api_key":"sk-test",
|
||||
"custom_api_url":"https://api.deepseek.com/v1",
|
||||
"custom_model_name":"deepseek-chat"
|
||||
}`)
|
||||
|
||||
exchangeResp := a.toolManageExchangeConfig("user-1", `{
|
||||
"action":"create",
|
||||
"exchange_type":"okx",
|
||||
"account_name":"test",
|
||||
"enabled":true
|
||||
}`)
|
||||
if strings.Contains(exchangeResp, `"error"`) {
|
||||
t.Fatalf("failed to create exchange: %s", exchangeResp)
|
||||
}
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 25, "zh", "把test这个交易所改一下")
|
||||
if err != nil {
|
||||
t.Fatalf("enter exchange update flow error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "请告诉我你要改什么") {
|
||||
t.Fatalf("expected exchange update prompt, got %q", resp)
|
||||
}
|
||||
|
||||
resp, err = a.thinkAndAct(context.Background(), "user-1", 25, "zh", "不改")
|
||||
if err != nil {
|
||||
t.Fatalf("cancel exchange flow error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "已取消当前流程") {
|
||||
t.Fatalf("expected flow cancellation, got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySkillSessionInputInterruptsOnDeflection(t *testing.T) {
|
||||
session := skillSession{Name: "exchange_management", Action: "update"}
|
||||
a := &Agent{}
|
||||
|
||||
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "你能帮我看下报错吗"); got != "interrupt" {
|
||||
t.Fatalf("expected diagnosis deflection to interrupt current skill flow, got %q", got)
|
||||
}
|
||||
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "换话题了大哥"); got != "cancel" {
|
||||
t.Fatalf("expected topic shift to cancel current skill flow, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
type skillSessionClassifierAIClient struct {
|
||||
lastSystemPrompt string
|
||||
lastUserPrompt string
|
||||
response string
|
||||
}
|
||||
|
||||
func (c *skillSessionClassifierAIClient) SetAPIKey(string, string, string) {}
|
||||
func (c *skillSessionClassifierAIClient) SetTimeout(time.Duration) {}
|
||||
func (c *skillSessionClassifierAIClient) CallWithMessages(string, string) (string, error) {
|
||||
return "", errors.New("unexpected CallWithMessages")
|
||||
}
|
||||
func (c *skillSessionClassifierAIClient) CallWithRequest(req *mcp.Request) (string, error) {
|
||||
if len(req.Messages) > 0 {
|
||||
c.lastSystemPrompt = req.Messages[0].Content
|
||||
}
|
||||
if len(req.Messages) > 1 {
|
||||
c.lastUserPrompt = req.Messages[1].Content
|
||||
}
|
||||
return c.response, nil
|
||||
}
|
||||
func (c *skillSessionClassifierAIClient) CallWithRequestStream(*mcp.Request, func(string)) (string, error) {
|
||||
return "", errors.New("unexpected CallWithRequestStream")
|
||||
}
|
||||
func (c *skillSessionClassifierAIClient) CallWithRequestFull(*mcp.Request) (*mcp.LLMResponse, error) {
|
||||
return nil, errors.New("unexpected CallWithRequestFull")
|
||||
}
|
||||
|
||||
func TestClassifySkillSessionInputUsesSlotExpectationWithoutLLM(t *testing.T) {
|
||||
client := &skillSessionClassifierAIClient{response: `{"decision":"interrupt"}`}
|
||||
a := &Agent{aiClient: client}
|
||||
session := skillSession{
|
||||
Name: "strategy_management",
|
||||
Action: "update_config",
|
||||
Fields: map[string]string{
|
||||
skillDAGStepField: "resolve_config_value",
|
||||
"config_field": "min_confidence",
|
||||
},
|
||||
}
|
||||
|
||||
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "70"); got != "continue" {
|
||||
t.Fatalf("expected numeric slot fill to continue, got %q", got)
|
||||
}
|
||||
if client.lastSystemPrompt != "" {
|
||||
t.Fatalf("expected no LLM call for direct slot expectation, got prompt %q", client.lastSystemPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySkillSessionInputUsesLLMOnlyForAmbiguousDeflection(t *testing.T) {
|
||||
client := &skillSessionClassifierAIClient{response: `{"decision":"interrupt"}`}
|
||||
a := &Agent{
|
||||
aiClient: client,
|
||||
history: newChatHistory(10),
|
||||
}
|
||||
session := skillSession{
|
||||
Name: "exchange_management",
|
||||
Action: "update",
|
||||
Fields: map[string]string{
|
||||
skillDAGStepField: "collect_account_name",
|
||||
},
|
||||
}
|
||||
|
||||
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "你能帮我看下报错吗"); got != "interrupt" {
|
||||
t.Fatalf("expected ambiguous deflection to interrupt, got %q", got)
|
||||
}
|
||||
if !strings.Contains(client.lastSystemPrompt, "classify one user message while a NOFXi structured management flow is active") {
|
||||
t.Fatalf("expected LLM classifier prompt, got %q", client.lastSystemPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySkillSessionInputUsesLLMForUnmatchedActiveSessionInput(t *testing.T) {
|
||||
client := &skillSessionClassifierAIClient{response: `{"decision":"continue"}`}
|
||||
a := &Agent{
|
||||
aiClient: client,
|
||||
history: newChatHistory(10),
|
||||
}
|
||||
session := skillSession{
|
||||
Name: "model_management",
|
||||
Action: "create",
|
||||
Fields: map[string]string{
|
||||
skillDAGStepField: "collect_optional_fields",
|
||||
"provider": "openai",
|
||||
},
|
||||
}
|
||||
|
||||
if got := a.classifySkillSessionInput(context.Background(), 0, "zh", session, "新增一个"); got != "continue" {
|
||||
t.Fatalf("expected unmatched active-session input to follow LLM decision, got %q", got)
|
||||
}
|
||||
if !strings.Contains(client.lastSystemPrompt, "classify one user message while a NOFXi structured management flow is active") {
|
||||
t.Fatalf("expected LLM classifier prompt, got %q", client.lastSystemPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyManagementCanDescribeDefaultConfig(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
_ = a.toolManageModelConfig("user-1", `{
|
||||
"action":"create",
|
||||
"provider":"deepseek",
|
||||
"enabled":true,
|
||||
"api_key":"sk-test",
|
||||
"custom_api_url":"https://api.deepseek.com/v1",
|
||||
"custom_model_name":"deepseek-chat"
|
||||
}`)
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 22, "zh", "看一下默认配置")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() default config error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "默认策略模板") || !strings.Contains(resp, "最低置信度") {
|
||||
t.Fatalf("expected default strategy config response, got %q", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyManagementSupportsMultiFieldConfigUpdate(t *testing.T) {
|
||||
a := newTestAgentWithStore(t)
|
||||
_ = a.toolManageModelConfig("user-1", `{
|
||||
"action":"create",
|
||||
"provider":"deepseek",
|
||||
"enabled":true,
|
||||
"api_key":"sk-test",
|
||||
"custom_api_url":"https://api.deepseek.com/v1",
|
||||
"custom_model_name":"deepseek-chat"
|
||||
}`)
|
||||
|
||||
createResp := a.toolManageStrategy("user-1", `{
|
||||
"action":"create",
|
||||
"name":"趋势策略A",
|
||||
"lang":"zh"
|
||||
}`)
|
||||
if strings.Contains(createResp, `"error"`) {
|
||||
t.Fatalf("failed to create strategy: %s", createResp)
|
||||
}
|
||||
|
||||
resp, err := a.thinkAndAct(context.Background(), "user-1", 23, "zh", "把趋势策略A的最小置信度改成70,核心指标都全选")
|
||||
if err != nil {
|
||||
t.Fatalf("thinkAndAct() multi-field update error = %v", err)
|
||||
}
|
||||
if !strings.Contains(resp, "最小置信度") || !strings.Contains(resp, "EMA") {
|
||||
t.Fatalf("expected multi-field update confirmation, got %q", resp)
|
||||
}
|
||||
|
||||
strategiesRaw := a.toolGetStrategies("user-1")
|
||||
if !strings.Contains(strategiesRaw, `"min_confidence":70`) ||
|
||||
!strings.Contains(strategiesRaw, `"enable_ema":true`) ||
|
||||
!strings.Contains(strategiesRaw, `"enable_macd":true`) ||
|
||||
!strings.Contains(strategiesRaw, `"enable_rsi":true`) ||
|
||||
!strings.Contains(strategiesRaw, `"enable_atr":true`) ||
|
||||
!strings.Contains(strategiesRaw, `"enable_boll":true`) {
|
||||
t.Fatalf("expected strategy config to include updated confidence and indicators, got %s", strategiesRaw)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,931 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
var urlPattern = regexp.MustCompile(`https://[^\s"'<>]+`)
|
||||
|
||||
func detectTraderManagementIntent(text string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if lower == "" {
|
||||
return false
|
||||
}
|
||||
return containsAny(lower, []string{"交易员", "trader", "agent"}) &&
|
||||
containsAny(lower, []string{"修改", "编辑", "更新", "改", "改一下", "删除", "删了", "启动", "停止", "查看", "查询", "列出", "rename", "update", "delete", "start", "stop", "list", "show"})
|
||||
}
|
||||
|
||||
func detectExchangeManagementIntent(text string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if lower == "" {
|
||||
return false
|
||||
}
|
||||
return containsAny(lower, []string{"交易所", "exchange", "okx", "binance", "bybit", "gate", "kucoin", "hyperliquid"}) &&
|
||||
containsAny(lower, []string{"创建", "新建", "修改", "编辑", "更新", "改", "改一下", "删除", "删了", "查询", "查看", "列出", "启用", "禁用", "改名", "rename", "create", "update", "delete", "list", "show", "enable", "disable"})
|
||||
}
|
||||
|
||||
func detectModelManagementIntent(text string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if lower == "" {
|
||||
return false
|
||||
}
|
||||
return containsAny(lower, []string{"模型", "model", "provider", "deepseek", "openai", "claude", "gemini", "qwen", "kimi", "grok", "minimax"}) &&
|
||||
containsAny(lower, []string{"创建", "新建", "修改", "编辑", "更新", "改", "改一下", "删除", "删了", "查询", "查看", "列出", "启用", "禁用", "改名", "rename", "create", "update", "delete", "list", "show", "enable", "disable"})
|
||||
}
|
||||
|
||||
func detectStrategyManagementIntent(text string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if lower == "" {
|
||||
return false
|
||||
}
|
||||
if wantsDefaultStrategyConfig(text) {
|
||||
return true
|
||||
}
|
||||
return containsAny(lower, []string{"策略", "strategy"}) &&
|
||||
containsAny(lower, []string{"创建", "新建", "修改", "编辑", "更新", "改", "改一下", "改成", "改为", "删除", "删了", "查询", "查看", "列出", "激活", "复制", "参数", "配置", "详情", "详细", "prompt", "提示词", "什么样", "怎么样", "create", "update", "delete", "list", "show", "activate", "duplicate", "detail", "details", "config", "configuration", "parameter", "prompt", "what kind"})
|
||||
}
|
||||
|
||||
func detectTraderDiagnosisSkill(text string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
return containsAny(lower, []string{"交易员", "trader"}) &&
|
||||
containsAny(lower, []string{"启动失败", "不交易", "没开仓", "无法启动", "异常", "失败", "diagnose", "error", "not trading"})
|
||||
}
|
||||
|
||||
func detectStrategyDiagnosisSkill(text string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
return containsAny(lower, []string{"策略", "strategy", "prompt"}) &&
|
||||
containsAny(lower, []string{"不生效", "没生效", "异常", "失败", "不一致", "失效", "diagnose", "error"})
|
||||
}
|
||||
|
||||
func detectManagementAction(text string, domain string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if lower == "" {
|
||||
return ""
|
||||
}
|
||||
hasUpdateVerb := containsAny(lower, []string{"修改", "编辑", "更新", "改", "rename", "update", "切换", "换成", "换到"})
|
||||
switch {
|
||||
case containsAny(lower, []string{"删除", "删掉", "删了", "remove", "delete"}):
|
||||
return "delete"
|
||||
case containsAny(lower, []string{"启动", "开始", "run", "start"}) && domain == "trader":
|
||||
return "start"
|
||||
case containsAny(lower, []string{"停止", "停掉", "stop", "pause"}) && domain == "trader":
|
||||
return "stop"
|
||||
case containsAny(lower, []string{"激活", "activate"}) && domain == "strategy":
|
||||
return "activate"
|
||||
case containsAny(lower, []string{"复制", "duplicate"}) && domain == "strategy":
|
||||
return "duplicate"
|
||||
case containsAny(lower, []string{"改名", "重命名", "rename"}):
|
||||
return "update_name"
|
||||
case domain == "trader" && containsAny(lower, []string{"换模型", "换交易所", "换策略", "绑定", "切换模型", "切换交易所", "切换策略"}):
|
||||
return "update_bindings"
|
||||
case (domain == "exchange" || domain == "model") && containsAny(lower, []string{"启用", "禁用", "enable", "disable"}):
|
||||
return "update_status"
|
||||
case domain == "model" && hasUpdateVerb && containsAny(lower, []string{"url", "endpoint", "地址", "接口"}):
|
||||
return "update_endpoint"
|
||||
case domain == "strategy" && hasUpdateVerb && containsAny(lower, []string{"prompt", "提示词"}):
|
||||
return "update_prompt"
|
||||
case domain == "strategy" && hasUpdateVerb && containsAny(lower, []string{
|
||||
"参数", "配置", "config", "configuration", "parameter",
|
||||
"最大持仓", "最小置信度", "最低置信度", "主周期", "多周期", "时间框架",
|
||||
"btc/eth杠杆", "btc eth杠杆", "山寨币杠杆",
|
||||
"核心指标", "ema", "macd", "rsi", "atr", "boll", "bollinger", "布林",
|
||||
}):
|
||||
return "update_config"
|
||||
case containsAny(lower, []string{"修改", "编辑", "更新", "改", "rename", "update"}):
|
||||
return "update"
|
||||
case domain == "trader" && containsAny(lower, []string{"运行中的", "在跑", "running"}):
|
||||
return "query_running"
|
||||
case !containsAny(lower, []string{"创建", "新建", "create", "new"}) &&
|
||||
containsAny(lower, []string{"详情", "详细", "prompt", "提示词", "什么样", "怎么样", "detail", "details", "what kind"}):
|
||||
return "query_detail"
|
||||
case containsAny(lower, []string{"查询", "查看", "列出", "list", "show", "有哪些"}):
|
||||
return "query_list"
|
||||
case containsAny(lower, []string{"创建", "新建", "加一个", "create", "new"}):
|
||||
return "create"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func exchangeTypeFromText(text string) string {
|
||||
lower := strings.ToLower(text)
|
||||
candidates := []string{"binance", "okx", "bybit", "gate", "kucoin", "hyperliquid", "aster", "lighter"}
|
||||
for _, candidate := range candidates {
|
||||
if strings.Contains(lower, candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(text, "币安"):
|
||||
return "binance"
|
||||
case strings.Contains(text, "欧易"):
|
||||
return "okx"
|
||||
case strings.Contains(text, "库币"):
|
||||
return "kucoin"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func providerFromText(text string) string {
|
||||
lower := strings.ToLower(text)
|
||||
candidates := []string{"openai", "deepseek", "claude", "gemini", "qwen", "kimi", "grok", "minimax"}
|
||||
for _, candidate := range candidates {
|
||||
if strings.Contains(lower, candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "通义") {
|
||||
return "qwen"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractURL(text string) string {
|
||||
return strings.TrimSpace(urlPattern.FindString(text))
|
||||
}
|
||||
|
||||
func extractPostKeywordName(text string, keywords []string) string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
for _, keyword := range keywords {
|
||||
if idx := strings.Index(trimmed, keyword); idx >= 0 {
|
||||
name := strings.TrimSpace(trimmed[idx+len(keyword):])
|
||||
name = strings.Trim(name, "“”\"':: ")
|
||||
if name != "" && len([]rune(name)) <= 50 {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func setField(session *skillSession, key, value string) {
|
||||
ensureSkillFields(session)
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
session.Fields[key] = value
|
||||
}
|
||||
|
||||
func fieldValue(session skillSession, key string) string {
|
||||
if session.Fields == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(session.Fields[key])
|
||||
}
|
||||
|
||||
func textMeansAllTargets(text string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if lower == "" {
|
||||
return false
|
||||
}
|
||||
return containsAny(lower, []string{
|
||||
"全部", "所有", "全都", "全部策略", "所有策略",
|
||||
"all", "all strategies", "every strategy",
|
||||
})
|
||||
}
|
||||
|
||||
func supportsBulkTargetSelection(skillName, action string) bool {
|
||||
return skillName == "strategy_management" && action == "delete"
|
||||
}
|
||||
|
||||
func resolveTargetFromText(text string, options []traderSkillOption, existing *EntityReference) *EntityReference {
|
||||
if existing != nil && (existing.ID != "" || existing.Name != "") {
|
||||
return existing
|
||||
}
|
||||
if match := pickMentionedOption(text, options); match != nil {
|
||||
return &EntityReference{ID: match.ID, Name: match.Name}
|
||||
}
|
||||
if choice := choosePreferredOption(options); choice != nil {
|
||||
return &EntityReference{ID: choice.ID, Name: choice.Name}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleTraderManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
|
||||
action := detectManagementAction(text, "trader")
|
||||
if session.Name == "trader_management" && session.Action != "" {
|
||||
action = session.Action
|
||||
}
|
||||
if action == "" || action == "create" {
|
||||
return "", false
|
||||
}
|
||||
if action == "query_running" {
|
||||
answer := formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID))
|
||||
return applyTraderQueryFilter(lang, answer, a.toolListTraders(storeUserID), "running_only"), true
|
||||
}
|
||||
if action == "query_detail" {
|
||||
options := a.loadTraderOptions(storeUserID)
|
||||
target := resolveTargetFromText(text, options, session.TargetRef)
|
||||
if detail, ok := a.describeTrader(storeUserID, lang, target); ok {
|
||||
return detail, true
|
||||
}
|
||||
return formatReadFastPathResponse(lang, "list_traders", a.toolListTraders(storeUserID)), true
|
||||
}
|
||||
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "trader_management", action, a.loadTraderOptions(storeUserID))
|
||||
}
|
||||
|
||||
func (a *Agent) handleExchangeManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
|
||||
action := detectManagementAction(text, "exchange")
|
||||
if session.Name == "exchange_management" && session.Action != "" {
|
||||
action = session.Action
|
||||
}
|
||||
if action == "" {
|
||||
return "", false
|
||||
}
|
||||
options := a.loadExchangeOptions(storeUserID)
|
||||
switch action {
|
||||
case "query_list":
|
||||
return formatReadFastPathResponse(lang, "get_exchange_configs", a.toolGetExchangeConfigs(storeUserID)), true
|
||||
case "query_detail":
|
||||
target := resolveTargetFromText(text, options, session.TargetRef)
|
||||
if detail, ok := a.describeExchange(storeUserID, lang, target); ok {
|
||||
return detail, true
|
||||
}
|
||||
return formatReadFastPathResponse(lang, "get_exchange_configs", a.toolGetExchangeConfigs(storeUserID)), true
|
||||
case "create":
|
||||
return a.handleExchangeCreateSkill(storeUserID, userID, lang, text, session), true
|
||||
default:
|
||||
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "exchange_management", action, options)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) handleModelManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
|
||||
action := detectManagementAction(text, "model")
|
||||
if session.Name == "model_management" && session.Action != "" {
|
||||
action = session.Action
|
||||
}
|
||||
if action == "" {
|
||||
return "", false
|
||||
}
|
||||
options := a.loadEnabledModelOptions(storeUserID)
|
||||
switch action {
|
||||
case "query_list":
|
||||
return formatReadFastPathResponse(lang, "get_model_configs", a.toolGetModelConfigs(storeUserID)), true
|
||||
case "query_detail":
|
||||
target := resolveTargetFromText(text, options, session.TargetRef)
|
||||
if detail, ok := a.describeModel(storeUserID, lang, target); ok {
|
||||
return detail, true
|
||||
}
|
||||
return formatReadFastPathResponse(lang, "get_model_configs", a.toolGetModelConfigs(storeUserID)), true
|
||||
case "create":
|
||||
return a.handleModelCreateSkill(storeUserID, userID, lang, text, session), true
|
||||
default:
|
||||
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "model_management", action, options)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) handleStrategyManagementSkill(storeUserID string, userID int64, lang, text string, session skillSession) (string, bool) {
|
||||
action := detectManagementAction(text, "strategy")
|
||||
if session.Name == "strategy_management" && session.Action != "" {
|
||||
action = session.Action
|
||||
}
|
||||
if action == "" && wantsStrategyDetails(text) {
|
||||
action = "query_detail"
|
||||
}
|
||||
if action == "" {
|
||||
return "", false
|
||||
}
|
||||
options := a.loadStrategyOptions(storeUserID)
|
||||
switch action {
|
||||
case "query_detail":
|
||||
if wantsDefaultStrategyConfig(text) {
|
||||
return a.describeDefaultStrategyConfig(lang), true
|
||||
}
|
||||
target := resolveTargetFromText(text, options, session.TargetRef)
|
||||
if detail, ok := a.describeStrategy(storeUserID, lang, target); ok {
|
||||
return detail, true
|
||||
}
|
||||
return formatReadFastPathResponse(lang, "get_strategies", a.toolGetStrategies(storeUserID)), true
|
||||
case "query_list":
|
||||
return formatReadFastPathResponse(lang, "get_strategies", a.toolGetStrategies(storeUserID)), true
|
||||
case "create":
|
||||
return a.handleStrategyCreateSkill(storeUserID, userID, lang, text, session), true
|
||||
default:
|
||||
return a.handleSimpleEntitySkill(storeUserID, userID, lang, text, session, "strategy_management", action, options)
|
||||
}
|
||||
}
|
||||
|
||||
func wantsStrategyDetails(text string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if lower == "" {
|
||||
return false
|
||||
}
|
||||
return containsAny(lower, []string{
|
||||
"什么样", "怎么样", "详情", "详细", "参数", "配置", "prompt", "提示词",
|
||||
"what kind", "details", "detail", "config", "configuration", "parameter", "prompt",
|
||||
})
|
||||
}
|
||||
|
||||
func wantsDefaultStrategyConfig(text string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if lower == "" {
|
||||
return false
|
||||
}
|
||||
return containsAny(lower, []string{
|
||||
"默认配置", "默认策略", "默认模板", "模板配置",
|
||||
"default config", "default strategy", "default template",
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Agent) describeStrategy(storeUserID, lang string, target *EntityReference) (string, bool) {
|
||||
if a.store == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var strategy *store.Strategy
|
||||
var err error
|
||||
if target != nil && strings.TrimSpace(target.ID) != "" {
|
||||
strategy, err = a.store.Strategy().Get(storeUserID, strings.TrimSpace(target.ID))
|
||||
} else if target != nil && strings.TrimSpace(target.Name) != "" {
|
||||
strategies, listErr := a.store.Strategy().List(storeUserID)
|
||||
if listErr != nil {
|
||||
return "", false
|
||||
}
|
||||
for _, item := range strategies {
|
||||
if item != nil && strings.EqualFold(strings.TrimSpace(item.Name), strings.TrimSpace(target.Name)) {
|
||||
strategy = item
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
strategies, listErr := a.store.Strategy().List(storeUserID)
|
||||
if listErr != nil || len(strategies) != 1 {
|
||||
return "", false
|
||||
}
|
||||
strategy = strategies[0]
|
||||
}
|
||||
if err != nil || strategy == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var cfg store.StrategyConfig
|
||||
if strings.TrimSpace(strategy.Config) != "" {
|
||||
_ = json.Unmarshal([]byte(strategy.Config), &cfg)
|
||||
}
|
||||
|
||||
return formatStrategyDetailResponse(lang, strategy, cfg), true
|
||||
}
|
||||
|
||||
func formatStrategyDetailResponse(lang string, strategy *store.Strategy, cfg store.StrategyConfig) string {
|
||||
name := strings.TrimSpace(strategy.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(strategy.ID)
|
||||
}
|
||||
|
||||
sourceBits := make([]string, 0, 4)
|
||||
if strings.TrimSpace(cfg.CoinSource.SourceType) != "" {
|
||||
sourceBits = append(sourceBits, cfg.CoinSource.SourceType)
|
||||
}
|
||||
if cfg.CoinSource.UseAI500 {
|
||||
sourceBits = append(sourceBits, fmt.Sprintf("AI500=%d", cfg.CoinSource.AI500Limit))
|
||||
}
|
||||
if cfg.CoinSource.UseOITop {
|
||||
sourceBits = append(sourceBits, fmt.Sprintf("OITop=%d", cfg.CoinSource.OITopLimit))
|
||||
}
|
||||
if cfg.CoinSource.UseOILow {
|
||||
sourceBits = append(sourceBits, fmt.Sprintf("OILow=%d", cfg.CoinSource.OILowLimit))
|
||||
}
|
||||
if len(cfg.CoinSource.StaticCoins) > 0 {
|
||||
sourceBits = append(sourceBits, "static="+strings.Join(cfg.CoinSource.StaticCoins, ","))
|
||||
}
|
||||
|
||||
timeframes := append([]string(nil), cfg.Indicators.Klines.SelectedTimeframes...)
|
||||
if len(timeframes) == 0 {
|
||||
timeframes = cleanStringList([]string{cfg.Indicators.Klines.PrimaryTimeframe, cfg.Indicators.Klines.LongerTimeframe})
|
||||
}
|
||||
|
||||
indicatorBits := make([]string, 0, 8)
|
||||
if cfg.Indicators.EnableRawKlines {
|
||||
indicatorBits = append(indicatorBits, "raw_klines")
|
||||
}
|
||||
if cfg.Indicators.EnableVolume {
|
||||
indicatorBits = append(indicatorBits, "volume")
|
||||
}
|
||||
if cfg.Indicators.EnableOI {
|
||||
indicatorBits = append(indicatorBits, "oi")
|
||||
}
|
||||
if cfg.Indicators.EnableFundingRate {
|
||||
indicatorBits = append(indicatorBits, "funding_rate")
|
||||
}
|
||||
if cfg.Indicators.EnableEMA {
|
||||
indicatorBits = append(indicatorBits, "ema")
|
||||
}
|
||||
if cfg.Indicators.EnableMACD {
|
||||
indicatorBits = append(indicatorBits, "macd")
|
||||
}
|
||||
if cfg.Indicators.EnableRSI {
|
||||
indicatorBits = append(indicatorBits, "rsi")
|
||||
}
|
||||
if cfg.Indicators.EnableATR {
|
||||
indicatorBits = append(indicatorBits, "atr")
|
||||
}
|
||||
if cfg.Indicators.EnableBOLL {
|
||||
indicatorBits = append(indicatorBits, "boll")
|
||||
}
|
||||
sort.Strings(indicatorBits)
|
||||
|
||||
promptBits := make([]string, 0, 5)
|
||||
if strings.TrimSpace(cfg.PromptSections.RoleDefinition) != "" {
|
||||
promptBits = append(promptBits, "role_definition")
|
||||
}
|
||||
if strings.TrimSpace(cfg.PromptSections.TradingFrequency) != "" {
|
||||
promptBits = append(promptBits, "trading_frequency")
|
||||
}
|
||||
if strings.TrimSpace(cfg.PromptSections.EntryStandards) != "" {
|
||||
promptBits = append(promptBits, "entry_standards")
|
||||
}
|
||||
if strings.TrimSpace(cfg.PromptSections.DecisionProcess) != "" {
|
||||
promptBits = append(promptBits, "decision_process")
|
||||
}
|
||||
|
||||
customPrompt := strings.TrimSpace(cfg.CustomPrompt)
|
||||
customPromptPreview := customPrompt
|
||||
if len([]rune(customPromptPreview)) > 120 {
|
||||
runes := []rune(customPromptPreview)
|
||||
customPromptPreview = string(runes[:120]) + "..."
|
||||
}
|
||||
|
||||
if lang == "zh" {
|
||||
lines := []string{
|
||||
fmt.Sprintf("策略“%s”概览:", name),
|
||||
fmt.Sprintf("- 类型:%s", defaultIfEmpty(strings.TrimSpace(cfg.StrategyType), "ai_trading")),
|
||||
fmt.Sprintf("- 语言:%s", defaultIfEmpty(strings.TrimSpace(cfg.Language), "zh")),
|
||||
}
|
||||
if strings.TrimSpace(strategy.Description) != "" {
|
||||
lines = append(lines, fmt.Sprintf("- 描述:%s", strings.TrimSpace(strategy.Description)))
|
||||
}
|
||||
if len(sourceBits) > 0 {
|
||||
lines = append(lines, "- 标的来源:"+strings.Join(sourceBits, " | "))
|
||||
}
|
||||
if len(timeframes) > 0 {
|
||||
lines = append(lines, "- K线周期:"+strings.Join(timeframes, " / "))
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- 仓位风险:最多持仓 %d,BTC/ETH 最大杠杆 %d,山寨最大杠杆 %d,最低置信度 %d",
|
||||
cfg.RiskControl.MaxPositions, cfg.RiskControl.BTCETHMaxLeverage, cfg.RiskControl.AltcoinMaxLeverage, cfg.RiskControl.MinConfidence))
|
||||
if len(indicatorBits) > 0 {
|
||||
lines = append(lines, "- 已启用指标:"+strings.Join(indicatorBits, "、"))
|
||||
}
|
||||
if len(promptBits) > 0 {
|
||||
lines = append(lines, "- Prompt 模块:"+strings.Join(promptBits, "、"))
|
||||
}
|
||||
if customPromptPreview != "" {
|
||||
lines = append(lines, "- 自定义 Prompt:"+customPromptPreview)
|
||||
} else {
|
||||
lines = append(lines, "- 自定义 Prompt:当前为空,主要使用策略模板内置 prompt sections。")
|
||||
}
|
||||
lines = append(lines, "- 如果你要,我还可以继续展开这条策略的完整参数 JSON,或者逐段解释它的 prompt。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
fmt.Sprintf("Strategy %q overview:", name),
|
||||
fmt.Sprintf("- Type: %s", defaultIfEmpty(strings.TrimSpace(cfg.StrategyType), "ai_trading")),
|
||||
fmt.Sprintf("- Language: %s", defaultIfEmpty(strings.TrimSpace(cfg.Language), "en")),
|
||||
}
|
||||
if strings.TrimSpace(strategy.Description) != "" {
|
||||
lines = append(lines, fmt.Sprintf("- Description: %s", strings.TrimSpace(strategy.Description)))
|
||||
}
|
||||
if len(sourceBits) > 0 {
|
||||
lines = append(lines, "- Coin source: "+strings.Join(sourceBits, " | "))
|
||||
}
|
||||
if len(timeframes) > 0 {
|
||||
lines = append(lines, "- Timeframes: "+strings.Join(timeframes, " / "))
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("- Risk: max positions %d, BTC/ETH max leverage %d, alt max leverage %d, min confidence %d",
|
||||
cfg.RiskControl.MaxPositions, cfg.RiskControl.BTCETHMaxLeverage, cfg.RiskControl.AltcoinMaxLeverage, cfg.RiskControl.MinConfidence))
|
||||
if len(indicatorBits) > 0 {
|
||||
lines = append(lines, "- Enabled indicators: "+strings.Join(indicatorBits, ", "))
|
||||
}
|
||||
if len(promptBits) > 0 {
|
||||
lines = append(lines, "- Prompt modules: "+strings.Join(promptBits, ", "))
|
||||
}
|
||||
if customPromptPreview != "" {
|
||||
lines = append(lines, "- Custom prompt: "+customPromptPreview)
|
||||
} else {
|
||||
lines = append(lines, "- Custom prompt: empty right now; it mainly uses the built-in prompt sections from the strategy template.")
|
||||
}
|
||||
lines = append(lines, "- I can also expand the full strategy config JSON or walk through the prompt section by section.")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (a *Agent) describeDefaultStrategyConfig(lang string) string {
|
||||
if lang != "zh" {
|
||||
lang = "en"
|
||||
}
|
||||
cfg := store.GetDefaultStrategyConfig(lang)
|
||||
name := "Default Strategy Template"
|
||||
description := "System default strategy configuration template"
|
||||
if lang == "zh" {
|
||||
name = "默认策略模板"
|
||||
description = "系统默认策略配置模板"
|
||||
}
|
||||
return formatStrategyDetailResponse(lang, &store.Strategy{
|
||||
ID: "default_strategy_template",
|
||||
Name: name,
|
||||
Description: description,
|
||||
}, cfg)
|
||||
}
|
||||
|
||||
func (a *Agent) describeTrader(storeUserID, lang string, target *EntityReference) (string, bool) {
|
||||
raw := a.toolListTraders(storeUserID)
|
||||
var payload struct {
|
||||
Traders []safeTraderToolConfig `json:"traders"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return "", false
|
||||
}
|
||||
trader := findTraderByReference(payload.Traders, target)
|
||||
if trader == nil {
|
||||
if len(payload.Traders) != 1 {
|
||||
return "", false
|
||||
}
|
||||
trader = &payload.Traders[0]
|
||||
}
|
||||
if lang == "zh" {
|
||||
status := "未运行"
|
||||
if trader.IsRunning {
|
||||
status = "运行中"
|
||||
}
|
||||
return fmt.Sprintf("交易员“%s”详情:\n- 状态:%s\n- 模型:%s\n- 交易所:%s\n- 策略:%s\n- 扫描间隔:%d 分钟\n- 初始余额:%.2f",
|
||||
trader.Name, status, trader.AIModelID, trader.ExchangeID, defaultIfEmpty(trader.StrategyID, "未绑定"), trader.ScanIntervalMinutes, trader.InitialBalance), true
|
||||
}
|
||||
status := "stopped"
|
||||
if trader.IsRunning {
|
||||
status = "running"
|
||||
}
|
||||
return fmt.Sprintf("Trader %q details:\n- Status: %s\n- Model: %s\n- Exchange: %s\n- Strategy: %s\n- Scan interval: %d minutes\n- Initial balance: %.2f",
|
||||
trader.Name, status, trader.AIModelID, trader.ExchangeID, defaultIfEmpty(trader.StrategyID, "none"), trader.ScanIntervalMinutes, trader.InitialBalance), true
|
||||
}
|
||||
|
||||
func (a *Agent) describeExchange(storeUserID, lang string, target *EntityReference) (string, bool) {
|
||||
raw := a.toolGetExchangeConfigs(storeUserID)
|
||||
var payload struct {
|
||||
ExchangeConfigs []safeExchangeToolConfig `json:"exchange_configs"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return "", false
|
||||
}
|
||||
exchange := findExchangeByReference(payload.ExchangeConfigs, target)
|
||||
if exchange == nil {
|
||||
if len(payload.ExchangeConfigs) != 1 {
|
||||
return "", false
|
||||
}
|
||||
exchange = &payload.ExchangeConfigs[0]
|
||||
}
|
||||
if lang == "zh" {
|
||||
return fmt.Sprintf("交易所配置“%s”详情:\n- 交易所:%s\n- 已启用:%t\n- API Key:%t\n- Secret:%t\n- Passphrase:%t\n- Testnet:%t",
|
||||
defaultIfEmpty(exchange.AccountName, exchange.ID), exchange.ExchangeType, exchange.Enabled, exchange.HasAPIKey, exchange.HasSecretKey, exchange.HasPassphrase, exchange.Testnet), true
|
||||
}
|
||||
return fmt.Sprintf("Exchange config %q details:\n- Exchange: %s\n- Enabled: %t\n- API key present: %t\n- Secret present: %t\n- Passphrase present: %t\n- Testnet: %t",
|
||||
defaultIfEmpty(exchange.AccountName, exchange.ID), exchange.ExchangeType, exchange.Enabled, exchange.HasAPIKey, exchange.HasSecretKey, exchange.HasPassphrase, exchange.Testnet), true
|
||||
}
|
||||
|
||||
func (a *Agent) describeModel(storeUserID, lang string, target *EntityReference) (string, bool) {
|
||||
raw := a.toolGetModelConfigs(storeUserID)
|
||||
var payload struct {
|
||||
ModelConfigs []safeModelToolConfig `json:"model_configs"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return "", false
|
||||
}
|
||||
model := findModelByReference(payload.ModelConfigs, target)
|
||||
if model == nil {
|
||||
if len(payload.ModelConfigs) != 1 {
|
||||
return "", false
|
||||
}
|
||||
model = &payload.ModelConfigs[0]
|
||||
}
|
||||
if lang == "zh" {
|
||||
return fmt.Sprintf("模型配置“%s”详情:\n- Provider:%s\n- 已启用:%t\n- API Key:%t\n- URL:%s\n- Model Name:%s",
|
||||
defaultIfEmpty(model.Name, model.ID), model.Provider, model.Enabled, model.HasAPIKey, defaultIfEmpty(model.CustomAPIURL, "未设置"), defaultIfEmpty(model.CustomModelName, "未设置")), true
|
||||
}
|
||||
return fmt.Sprintf("Model config %q details:\n- Provider: %s\n- Enabled: %t\n- API key present: %t\n- URL: %s\n- Model name: %s",
|
||||
defaultIfEmpty(model.Name, model.ID), model.Provider, model.Enabled, model.HasAPIKey, defaultIfEmpty(model.CustomAPIURL, "not set"), defaultIfEmpty(model.CustomModelName, "not set")), true
|
||||
}
|
||||
|
||||
func findTraderByReference(items []safeTraderToolConfig, target *EntityReference) *safeTraderToolConfig {
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range items {
|
||||
if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) {
|
||||
return &items[i]
|
||||
}
|
||||
if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(items[i].Name), strings.TrimSpace(target.Name)) {
|
||||
return &items[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findExchangeByReference(items []safeExchangeToolConfig, target *EntityReference) *safeExchangeToolConfig {
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range items {
|
||||
name := defaultIfEmpty(items[i].AccountName, items[i].Name)
|
||||
if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) {
|
||||
return &items[i]
|
||||
}
|
||||
if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(name), strings.TrimSpace(target.Name)) {
|
||||
return &items[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findModelByReference(items []safeModelToolConfig, target *EntityReference) *safeModelToolConfig {
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range items {
|
||||
if strings.TrimSpace(target.ID) != "" && items[i].ID == strings.TrimSpace(target.ID) {
|
||||
return &items[i]
|
||||
}
|
||||
if strings.TrimSpace(target.Name) != "" && strings.EqualFold(strings.TrimSpace(items[i].Name), strings.TrimSpace(target.Name)) {
|
||||
return &items[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) loadTraderOptions(storeUserID string) []traderSkillOption {
|
||||
if a.store == nil {
|
||||
return nil
|
||||
}
|
||||
traders, err := a.store.Trader().List(storeUserID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]traderSkillOption, 0, len(traders))
|
||||
for _, trader := range traders {
|
||||
out = append(out, traderSkillOption{ID: trader.ID, Name: trader.Name, Enabled: trader.IsRunning})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *Agent) handleExchangeCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string {
|
||||
if session.Name == "" {
|
||||
session = skillSession{Name: "exchange_management", Action: "create", Phase: "collecting"}
|
||||
}
|
||||
if fieldValue(session, skillDAGStepField) == "" {
|
||||
setSkillDAGStep(&session, "resolve_exchange_type")
|
||||
}
|
||||
if isCancelSkillReply(text) {
|
||||
a.clearSkillSession(userID)
|
||||
if lang == "zh" {
|
||||
return "已取消当前创建交易所配置流程。"
|
||||
}
|
||||
return "Cancelled the current exchange creation flow."
|
||||
}
|
||||
if v := exchangeTypeFromText(text); fieldValue(session, "exchange_type") == "" && v != "" {
|
||||
setField(&session, "exchange_type", v)
|
||||
}
|
||||
if v := extractTraderName(text); fieldValue(session, "account_name") == "" && v != "" {
|
||||
setField(&session, "account_name", v)
|
||||
}
|
||||
exType := fieldValue(session, "exchange_type")
|
||||
if actionRequiresSlot("exchange_management", "create", "exchange_type") && exType == "" {
|
||||
setSkillDAGStep(&session, "resolve_exchange_type")
|
||||
a.saveSkillSession(userID, session)
|
||||
if lang == "zh" {
|
||||
return "要创建交易所配置,我还需要:" + slotDisplayName("exchange_type", lang) + "。例如:OKX、Binance、Bybit。"
|
||||
}
|
||||
return "To create an exchange config, tell me which exchange to use, for example OKX, Binance, or Bybit."
|
||||
}
|
||||
accountName := fieldValue(session, "account_name")
|
||||
if accountName == "" {
|
||||
accountName = "Default"
|
||||
}
|
||||
setSkillDAGStep(&session, "execute_create")
|
||||
args := map[string]any{
|
||||
"action": "create",
|
||||
"exchange_type": exType,
|
||||
"account_name": accountName,
|
||||
}
|
||||
raw, _ := json.Marshal(args)
|
||||
resp := a.toolManageExchangeConfig(storeUserID, string(raw))
|
||||
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
|
||||
a.saveSkillSession(userID, session)
|
||||
if lang == "zh" {
|
||||
return "创建交易所配置失败:" + errMsg
|
||||
}
|
||||
return "Failed to create exchange config: " + errMsg
|
||||
}
|
||||
a.clearSkillSession(userID)
|
||||
if lang == "zh" {
|
||||
return fmt.Sprintf("已创建交易所配置:%s(%s)。如需继续补 API Key、Secret 或 Passphrase,可以直接继续说。", accountName, exType)
|
||||
}
|
||||
return fmt.Sprintf("Created exchange config %s (%s). You can continue by adding API key, secret, or passphrase.", accountName, exType)
|
||||
}
|
||||
|
||||
func (a *Agent) handleModelCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string {
|
||||
if session.Name == "" {
|
||||
session = skillSession{Name: "model_management", Action: "create", Phase: "collecting"}
|
||||
}
|
||||
if fieldValue(session, skillDAGStepField) == "" {
|
||||
setSkillDAGStep(&session, "resolve_provider")
|
||||
}
|
||||
if isCancelSkillReply(text) {
|
||||
a.clearSkillSession(userID)
|
||||
if lang == "zh" {
|
||||
return "已取消当前创建模型配置流程。"
|
||||
}
|
||||
return "Cancelled the current model creation flow."
|
||||
}
|
||||
if v := providerFromText(text); fieldValue(session, "provider") == "" && v != "" {
|
||||
setField(&session, "provider", v)
|
||||
}
|
||||
if v := extractTraderName(text); fieldValue(session, "name") == "" && v != "" {
|
||||
setField(&session, "name", v)
|
||||
}
|
||||
if v := extractURL(text); fieldValue(session, "custom_api_url") == "" && v != "" {
|
||||
setField(&session, "custom_api_url", v)
|
||||
}
|
||||
provider := fieldValue(session, "provider")
|
||||
if actionRequiresSlot("model_management", "create", "provider") && provider == "" {
|
||||
setSkillDAGStep(&session, "resolve_provider")
|
||||
a.saveSkillSession(userID, session)
|
||||
if lang == "zh" {
|
||||
return "要创建模型配置,我还需要:" + slotDisplayName("provider", lang) + ",例如:OpenAI、DeepSeek、Claude、Gemini。"
|
||||
}
|
||||
return "To create a model config, I need the provider first, for example OpenAI, DeepSeek, Claude, or Gemini."
|
||||
}
|
||||
setSkillDAGStep(&session, "execute_create")
|
||||
args := map[string]any{
|
||||
"action": "create",
|
||||
"provider": provider,
|
||||
"name": defaultIfEmpty(fieldValue(session, "name"), provider),
|
||||
"custom_api_url": fieldValue(session, "custom_api_url"),
|
||||
"custom_model_name": fieldValue(session, "custom_model_name"),
|
||||
}
|
||||
raw, _ := json.Marshal(args)
|
||||
resp := a.toolManageModelConfig(storeUserID, string(raw))
|
||||
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
|
||||
a.saveSkillSession(userID, session)
|
||||
if lang == "zh" {
|
||||
return "创建模型配置失败:" + errMsg
|
||||
}
|
||||
return "Failed to create model config: " + errMsg
|
||||
}
|
||||
a.clearSkillSession(userID)
|
||||
if lang == "zh" {
|
||||
return fmt.Sprintf("已创建模型配置:%s。你后续还可以继续补 API Key、URL 或模型名。", provider)
|
||||
}
|
||||
return fmt.Sprintf("Created model config for %s. You can continue by adding API key, URL, or model name.", provider)
|
||||
}
|
||||
|
||||
func (a *Agent) handleStrategyCreateSkill(storeUserID string, userID int64, lang, text string, session skillSession) string {
|
||||
if session.Name == "" {
|
||||
session = skillSession{Name: "strategy_management", Action: "create", Phase: "collecting"}
|
||||
}
|
||||
if fieldValue(session, skillDAGStepField) == "" {
|
||||
setSkillDAGStep(&session, "resolve_name")
|
||||
}
|
||||
if isCancelSkillReply(text) {
|
||||
a.clearSkillSession(userID)
|
||||
if lang == "zh" {
|
||||
return "已取消当前创建策略流程。"
|
||||
}
|
||||
return "Cancelled the current strategy creation flow."
|
||||
}
|
||||
name := fieldValue(session, "name")
|
||||
if name == "" {
|
||||
name = extractTraderName(text)
|
||||
if name == "" {
|
||||
name = extractPostKeywordName(text, []string{"叫", "名为", "策略叫", "strategy called"})
|
||||
}
|
||||
if name != "" {
|
||||
setField(&session, "name", name)
|
||||
}
|
||||
}
|
||||
if actionRequiresSlot("strategy_management", "create", "name") && name == "" {
|
||||
setSkillDAGStep(&session, "resolve_name")
|
||||
a.saveSkillSession(userID, session)
|
||||
if lang == "zh" {
|
||||
return "要创建策略,我还需要:" + slotDisplayName("name", lang) + "。你可以直接说:创建一个叫“趋势策略A”的策略。"
|
||||
}
|
||||
return "To create a strategy, I need a strategy name. You can say: create a strategy called 'Trend A'."
|
||||
}
|
||||
setSkillDAGStep(&session, "execute_create")
|
||||
args := map[string]any{"action": "create", "name": name, "lang": "zh"}
|
||||
raw, _ := json.Marshal(args)
|
||||
resp := a.toolManageStrategy(storeUserID, string(raw))
|
||||
if errMsg := parseSkillError(resp); strings.Contains(resp, `"error"`) {
|
||||
a.saveSkillSession(userID, session)
|
||||
if lang == "zh" {
|
||||
return "创建策略失败:" + errMsg
|
||||
}
|
||||
return "Failed to create strategy: " + errMsg
|
||||
}
|
||||
a.clearSkillSession(userID)
|
||||
if lang == "zh" {
|
||||
return fmt.Sprintf("已创建策略“%s”。默认配置已就绪,你后续可以继续让我帮你改细节。", name)
|
||||
}
|
||||
return fmt.Sprintf("Created strategy %q with the default configuration.", name)
|
||||
}
|
||||
|
||||
func (a *Agent) handleSimpleEntitySkill(storeUserID string, userID int64, lang, text string, session skillSession, skillName, action string, options []traderSkillOption) (string, bool) {
|
||||
if isCancelSkillReply(text) {
|
||||
a.clearSkillSession(userID)
|
||||
if lang == "zh" {
|
||||
return "已取消当前流程。", true
|
||||
}
|
||||
return "Cancelled the current flow.", true
|
||||
}
|
||||
if session.Name == "" {
|
||||
session = skillSession{Name: skillName, Action: action, Phase: "collecting"}
|
||||
}
|
||||
if session.Name != skillName || session.Action != action {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if dag, ok := getSkillDAG(skillName, action); ok && len(dag.Steps) > 0 {
|
||||
currentStep, _ := currentSkillDAGStep(session)
|
||||
if currentStep.ID == "resolve_target" {
|
||||
if supportsBulkTargetSelection(skillName, action) && textMeansAllTargets(text) {
|
||||
setField(&session, "bulk_scope", "all")
|
||||
advanceSkillDAGStep(&session, currentStep.ID)
|
||||
} else {
|
||||
session.TargetRef = resolveTargetFromText(text, options, session.TargetRef)
|
||||
}
|
||||
if session.TargetRef == nil {
|
||||
if !(supportsBulkTargetSelection(skillName, action) && fieldValue(session, "bulk_scope") == "all") {
|
||||
setSkillDAGStep(&session, "resolve_target")
|
||||
a.saveSkillSession(userID, session)
|
||||
label := "可选对象:"
|
||||
if lang != "zh" {
|
||||
label = "Available targets:"
|
||||
}
|
||||
optionList := formatOptionList(label, options)
|
||||
if lang == "zh" {
|
||||
reply := "当前这一步需要先确定目标对象。请告诉我你要操作哪一个。"
|
||||
if optionList != "" {
|
||||
reply += "\n" + optionList
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
reply := "This step needs a target object first. Tell me which one to operate on."
|
||||
if optionList != "" {
|
||||
reply += "\n" + optionList
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
}
|
||||
if fieldValue(session, skillDAGStepField) == currentStep.ID {
|
||||
advanceSkillDAGStep(&session, currentStep.ID)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if supportsBulkTargetSelection(skillName, action) && textMeansAllTargets(text) {
|
||||
setField(&session, "bulk_scope", "all")
|
||||
} else {
|
||||
session.TargetRef = resolveTargetFromText(text, options, session.TargetRef)
|
||||
}
|
||||
if session.TargetRef == nil && fieldValue(session, "bulk_scope") != "all" && action != "query" && action != "query_list" && action != "query_detail" && action != "query_running" {
|
||||
a.saveSkillSession(userID, session)
|
||||
label := formatOptionList("可选对象:", options)
|
||||
if lang == "zh" {
|
||||
reply := "我还需要你明确要操作的是哪一个对象。"
|
||||
if label != "" {
|
||||
reply += "\n" + label
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
reply := "I still need you to specify which object to operate on."
|
||||
if label != "" {
|
||||
reply += "\n" + label
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
}
|
||||
|
||||
switch skillName {
|
||||
case "trader_management":
|
||||
return a.executeTraderManagementAction(storeUserID, userID, lang, text, session), true
|
||||
case "exchange_management":
|
||||
return a.executeExchangeManagementAction(storeUserID, userID, lang, text, session), true
|
||||
case "model_management":
|
||||
return a.executeModelManagementAction(storeUserID, userID, lang, text, session), true
|
||||
case "strategy_management":
|
||||
return a.executeStrategyManagementAction(storeUserID, userID, lang, text, session), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func defaultIfEmpty(value, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return strings.TrimSpace(fallback)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
const (
|
||||
skillOutcomeSuccess = "success"
|
||||
skillOutcomeNeedMoreInfo = "need_more_info"
|
||||
skillOutcomeRecoverableError = "recoverable_error"
|
||||
skillOutcomeFatalError = "fatal_error"
|
||||
skillOutcomeNotHandled = "not_handled"
|
||||
)
|
||||
|
||||
type skillOutcome struct {
|
||||
Skill string `json:"skill"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
GoalAchieved bool `json:"goal_achieved"`
|
||||
UserMessage string `json:"user_message,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type taskReviewDecision struct {
|
||||
Route string `json:"route"`
|
||||
Answer string `json:"answer,omitempty"`
|
||||
}
|
||||
|
||||
func normalizeAtomicSkillAction(skill, action string) string {
|
||||
action = strings.TrimSpace(strings.ToLower(action))
|
||||
switch skill {
|
||||
case "trader_management":
|
||||
switch action {
|
||||
case "query", "query_list":
|
||||
return "query_list"
|
||||
case "query_running":
|
||||
return "query_running"
|
||||
case "query_detail":
|
||||
return "query_detail"
|
||||
case "update":
|
||||
return "update_name"
|
||||
case "update_name", "update_bindings":
|
||||
return action
|
||||
}
|
||||
case "exchange_management":
|
||||
switch action {
|
||||
case "query", "query_list":
|
||||
return "query_list"
|
||||
case "query_detail":
|
||||
return "query_detail"
|
||||
case "update":
|
||||
return "update_name"
|
||||
case "update_name", "update_status":
|
||||
return action
|
||||
}
|
||||
case "model_management":
|
||||
switch action {
|
||||
case "query", "query_list":
|
||||
return "query_list"
|
||||
case "query_detail":
|
||||
return "query_detail"
|
||||
case "update":
|
||||
return "update_name"
|
||||
case "update_name", "update_endpoint", "update_status":
|
||||
return action
|
||||
}
|
||||
case "strategy_management":
|
||||
switch action {
|
||||
case "query", "query_list":
|
||||
return "query_list"
|
||||
case "query_detail":
|
||||
return "query_detail"
|
||||
case "update":
|
||||
return "update_name"
|
||||
case "update_name", "update_config", "update_prompt":
|
||||
return action
|
||||
}
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
func inferSkillOutcome(skill, action, answer string, activeSession skillSession, data map[string]any) skillOutcome {
|
||||
outcome := skillOutcome{
|
||||
Skill: skill,
|
||||
Action: action,
|
||||
Status: skillOutcomeSuccess,
|
||||
UserMessage: strings.TrimSpace(answer),
|
||||
Data: data,
|
||||
}
|
||||
if activeSession.Name != "" {
|
||||
outcome.Status = skillOutcomeNeedMoreInfo
|
||||
outcome.GoalAchieved = false
|
||||
return outcome
|
||||
}
|
||||
|
||||
lower := strings.ToLower(strings.TrimSpace(answer))
|
||||
switch {
|
||||
case lower == "":
|
||||
outcome.Status = skillOutcomeNotHandled
|
||||
case strings.Contains(lower, "失败") || strings.Contains(lower, "failed") || strings.Contains(lower, "error"):
|
||||
outcome.Status = skillOutcomeRecoverableError
|
||||
outcome.Error = strings.TrimSpace(answer)
|
||||
default:
|
||||
outcome.GoalAchieved = true
|
||||
}
|
||||
return outcome
|
||||
}
|
||||
|
||||
func parseTaskReviewDecision(raw string) (taskReviewDecision, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimPrefix(raw, "```json")
|
||||
raw = strings.TrimPrefix(raw, "```")
|
||||
raw = strings.TrimSuffix(raw, "```")
|
||||
raw = strings.TrimSpace(raw)
|
||||
|
||||
var decision taskReviewDecision
|
||||
if err := json.Unmarshal([]byte(raw), &decision); err == nil {
|
||||
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
|
||||
decision.Answer = strings.TrimSpace(decision.Answer)
|
||||
return decision, nil
|
||||
}
|
||||
start := strings.Index(raw, "{")
|
||||
end := strings.LastIndex(raw, "}")
|
||||
if start >= 0 && end > start {
|
||||
if err := json.Unmarshal([]byte(raw[start:end+1]), &decision); err == nil {
|
||||
decision.Route = strings.TrimSpace(strings.ToLower(decision.Route))
|
||||
decision.Answer = strings.TrimSpace(decision.Answer)
|
||||
return decision, nil
|
||||
}
|
||||
}
|
||||
return taskReviewDecision{}, fmt.Errorf("invalid task review json")
|
||||
}
|
||||
|
||||
func (a *Agent) reviewTaskCompletion(ctx context.Context, userID int64, lang, text string, outcome skillOutcome) (taskReviewDecision, error) {
|
||||
if a.aiClient == nil {
|
||||
if outcome.Status == skillOutcomeRecoverableError || outcome.Status == skillOutcomeFatalError || outcome.Status == skillOutcomeNotHandled {
|
||||
return taskReviewDecision{Route: "replan"}, nil
|
||||
}
|
||||
return taskReviewDecision{Route: "complete", Answer: outcome.UserMessage}, nil
|
||||
}
|
||||
|
||||
recentConversationCtx := a.buildRecentConversationContext(userID, text)
|
||||
outcomeJSON, _ := json.Marshal(outcome)
|
||||
systemPrompt := `You are the task-level Plan-Execute-Review supervisor for NOFXi.
|
||||
You are reviewing the JSON result returned by one structured skill execution.
|
||||
Return JSON only. Do not return markdown.
|
||||
|
||||
Rules:
|
||||
- Decide whether the OVERALL user task is finished, not whether the skill itself ran successfully.
|
||||
- Use route "complete" only when the user's task is now complete or the best next message is a final user-facing reply.
|
||||
- Use route "replan" when the user's task is not complete yet and the planner should continue from the new skill outcome.
|
||||
- Prefer route "replan" for recoverable errors, unmet goals, missing prerequisites, or cases where another skill/tool sequence may help.
|
||||
- If you choose "complete", produce the final user-facing answer in the user's language.
|
||||
|
||||
Return JSON with this exact shape:
|
||||
{"route":"complete|replan","answer":""}`
|
||||
userPrompt := fmt.Sprintf("Language: %s\nUser message: %s\n\nRecent conversation:\n%s\n\nSkill outcome JSON:\n%s", lang, text, recentConversationCtx, string(outcomeJSON))
|
||||
|
||||
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
|
||||
defer cancel()
|
||||
|
||||
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
|
||||
Messages: []mcp.Message{
|
||||
mcp.NewSystemMessage(systemPrompt),
|
||||
mcp.NewUserMessage(userPrompt),
|
||||
},
|
||||
Ctx: stageCtx,
|
||||
})
|
||||
if err != nil {
|
||||
return taskReviewDecision{}, err
|
||||
}
|
||||
return parseTaskReviewDecision(raw)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed skills/*.json
|
||||
var embeddedSkillDefinitions embed.FS
|
||||
|
||||
type SkillDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Domain string `json:"domain"`
|
||||
Description string `json:"description"`
|
||||
Intents []string `json:"intents,omitempty"`
|
||||
Actions map[string]SkillActionDefinition `json:"actions,omitempty"`
|
||||
ToolMapping map[string]string `json:"tool_mapping,omitempty"`
|
||||
}
|
||||
|
||||
type SkillActionDefinition struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
RequiredSlots []string `json:"required_slots,omitempty"`
|
||||
OptionalSlots []string `json:"optional_slots,omitempty"`
|
||||
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
|
||||
}
|
||||
|
||||
var skillRegistry = mustLoadSkillRegistry()
|
||||
|
||||
func mustLoadSkillRegistry() map[string]SkillDefinition {
|
||||
registry, err := loadSkillRegistry()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return registry
|
||||
}
|
||||
|
||||
func loadSkillRegistry() (map[string]SkillDefinition, error) {
|
||||
entries, err := embeddedSkillDefinitions.ReadDir("skills")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
registry := make(map[string]SkillDefinition, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
raw, err := embeddedSkillDefinitions.ReadFile("skills/" + entry.Name())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var def SkillDefinition
|
||||
if err := json.Unmarshal(raw, &def); err != nil {
|
||||
return nil, fmt.Errorf("parse skill definition %s: %w", entry.Name(), err)
|
||||
}
|
||||
def = normalizeSkillDefinition(def)
|
||||
if def.Name == "" {
|
||||
return nil, fmt.Errorf("skill definition %s has empty name", entry.Name())
|
||||
}
|
||||
registry[def.Name] = def
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
func normalizeSkillDefinition(def SkillDefinition) SkillDefinition {
|
||||
def.Name = strings.TrimSpace(def.Name)
|
||||
def.Kind = strings.TrimSpace(def.Kind)
|
||||
def.Domain = strings.TrimSpace(def.Domain)
|
||||
def.Description = strings.TrimSpace(def.Description)
|
||||
def.Intents = cleanStringList(def.Intents)
|
||||
|
||||
if len(def.Actions) > 0 {
|
||||
normalized := make(map[string]SkillActionDefinition, len(def.Actions))
|
||||
for key, action := range def.Actions {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
action.Description = strings.TrimSpace(action.Description)
|
||||
action.RequiredSlots = cleanStringList(action.RequiredSlots)
|
||||
action.OptionalSlots = cleanStringList(action.OptionalSlots)
|
||||
normalized[key] = action
|
||||
}
|
||||
def.Actions = normalized
|
||||
}
|
||||
|
||||
if len(def.ToolMapping) > 0 {
|
||||
normalized := make(map[string]string, len(def.ToolMapping))
|
||||
for key, value := range def.ToolMapping {
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
if key == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
normalized[key] = value
|
||||
}
|
||||
def.ToolMapping = normalized
|
||||
}
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
func getSkillDefinition(name string) (SkillDefinition, bool) {
|
||||
def, ok := skillRegistry[strings.TrimSpace(name)]
|
||||
return def, ok
|
||||
}
|
||||
|
||||
func listSkillNames() []string {
|
||||
names := make([]string, 0, len(skillRegistry))
|
||||
for name := range skillRegistry {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSkillRegistryLoadsDefinitions(t *testing.T) {
|
||||
names := listSkillNames()
|
||||
if len(names) < 4 {
|
||||
t.Fatalf("expected skill registry to load definitions, got %v", names)
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
"trader_management",
|
||||
"exchange_management",
|
||||
"model_management",
|
||||
"strategy_management",
|
||||
"exchange_diagnosis",
|
||||
"model_diagnosis",
|
||||
} {
|
||||
if _, ok := getSkillDefinition(name); !ok {
|
||||
t.Fatalf("missing skill definition %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraderManagementDefinitionHasCreateAction(t *testing.T) {
|
||||
def, ok := getSkillDefinition("trader_management")
|
||||
if !ok {
|
||||
t.Fatalf("missing trader_management definition")
|
||||
}
|
||||
action, ok := def.Actions["create"]
|
||||
if !ok {
|
||||
t.Fatalf("missing create action in trader_management")
|
||||
}
|
||||
if len(action.RequiredSlots) == 0 {
|
||||
t.Fatalf("expected required slots for trader_management create action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionNeedsConfirmationUsesSkillDefinition(t *testing.T) {
|
||||
if !actionNeedsConfirmation("exchange_management", "delete") {
|
||||
t.Fatalf("expected exchange_management delete to require confirmation")
|
||||
}
|
||||
if actionNeedsConfirmation("exchange_management", "query") {
|
||||
t.Fatalf("did not expect exchange_management query to require confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRequiresSlotUsesSkillDefinition(t *testing.T) {
|
||||
if !actionRequiresSlot("model_management", "create", "provider") {
|
||||
t.Fatalf("expected model_management create to require provider")
|
||||
}
|
||||
if actionRequiresSlot("model_management", "create", "target_ref") {
|
||||
t.Fatalf("did not expect model_management create to require target_ref")
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type skillActionRuntime struct {
|
||||
Skill SkillDefinition
|
||||
Name string
|
||||
Action SkillActionDefinition
|
||||
}
|
||||
|
||||
func getSkillActionRuntime(skillName, action string) (skillActionRuntime, bool) {
|
||||
def, ok := getSkillDefinition(skillName)
|
||||
if !ok {
|
||||
return skillActionRuntime{}, false
|
||||
}
|
||||
action = strings.TrimSpace(action)
|
||||
if action == "" {
|
||||
return skillActionRuntime{Skill: def}, true
|
||||
}
|
||||
actionDef, ok := def.Actions[action]
|
||||
if !ok {
|
||||
return skillActionRuntime{}, false
|
||||
}
|
||||
return skillActionRuntime{
|
||||
Skill: def,
|
||||
Name: action,
|
||||
Action: actionDef,
|
||||
}, true
|
||||
}
|
||||
|
||||
func actionNeedsConfirmation(skillName, action string) bool {
|
||||
runtime, ok := getSkillActionRuntime(skillName, action)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return runtime.Action.NeedsConfirmation
|
||||
}
|
||||
|
||||
func actionRequiresSlot(skillName, action, slot string) bool {
|
||||
runtime, ok := getSkillActionRuntime(skillName, action)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
slot = strings.TrimSpace(slot)
|
||||
for _, candidate := range runtime.Action.RequiredSlots {
|
||||
if candidate == slot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func slotDisplayName(slot, lang string) string {
|
||||
slot = strings.TrimSpace(slot)
|
||||
if lang != "zh" {
|
||||
switch slot {
|
||||
case "target_ref":
|
||||
return "target"
|
||||
case "name":
|
||||
return "name"
|
||||
case "exchange":
|
||||
return "exchange"
|
||||
case "model":
|
||||
return "model"
|
||||
case "strategy":
|
||||
return "strategy"
|
||||
case "exchange_type":
|
||||
return "exchange type"
|
||||
case "provider":
|
||||
return "provider"
|
||||
default:
|
||||
return slot
|
||||
}
|
||||
}
|
||||
switch slot {
|
||||
case "target_ref":
|
||||
return "目标对象"
|
||||
case "name":
|
||||
return "名称"
|
||||
case "exchange":
|
||||
return "交易所"
|
||||
case "model":
|
||||
return "模型"
|
||||
case "strategy":
|
||||
return "策略"
|
||||
case "exchange_type":
|
||||
return "交易所类型"
|
||||
case "provider":
|
||||
return "provider"
|
||||
default:
|
||||
return slot
|
||||
}
|
||||
}
|
||||
|
||||
func formatAwaitConfirmationMessage(lang, action, targetLabel string) string {
|
||||
actionLabel := action
|
||||
if lang == "zh" {
|
||||
switch action {
|
||||
case "start":
|
||||
actionLabel = "启动"
|
||||
case "stop":
|
||||
actionLabel = "停止"
|
||||
case "delete":
|
||||
actionLabel = "删除"
|
||||
case "activate":
|
||||
actionLabel = "激活"
|
||||
default:
|
||||
actionLabel = action
|
||||
}
|
||||
return fmt.Sprintf("即将%s“%s”。这是需要确认的操作,请回复“确认”继续,回复“取消”终止。", actionLabel, targetLabel)
|
||||
}
|
||||
return fmt.Sprintf("You are about to %s %q. Please reply 'confirm' to continue or 'cancel' to stop.", actionLabel, targetLabel)
|
||||
}
|
||||
|
||||
func formatStillWaitingConfirmationMessage(lang string) string {
|
||||
if lang == "zh" {
|
||||
return "当前流程仍在等待你确认。回复“确认”继续,或“取消”终止。"
|
||||
}
|
||||
return "This flow is still waiting for your confirmation."
|
||||
}
|
||||
|
||||
func beginConfirmationIfNeeded(userID int64, lang string, session *skillSession, targetLabel string) (string, bool) {
|
||||
if session == nil || !actionNeedsConfirmation(session.Name, session.Action) {
|
||||
return "", false
|
||||
}
|
||||
if session.Phase != "await_confirmation" {
|
||||
session.Phase = "await_confirmation"
|
||||
return formatAwaitConfirmationMessage(lang, session.Action, targetLabel), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func awaitingConfirmationButNotApproved(lang string, session skillSession, text string) (string, bool) {
|
||||
if !actionNeedsConfirmation(session.Name, session.Action) || session.Phase != "await_confirmation" {
|
||||
return "", false
|
||||
}
|
||||
if isYesReply(text) {
|
||||
return "", false
|
||||
}
|
||||
return formatStillWaitingConfirmationMessage(lang), true
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "exchange_diagnosis",
|
||||
"kind": "diagnosis",
|
||||
"domain": "exchange",
|
||||
"description": "当用户反馈交易所 API 连接失败、签名错误、timestamp 异常、权限不足、IP 白名单限制、账户不可用等问题时调用。适用于用户在手动配置或运行交易员时遇到的交易所接入故障。不用于创建、修改、删除或查询交易所配置这类管理操作。"
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "exchange_management",
|
||||
"kind": "management",
|
||||
"domain": "exchange",
|
||||
"description": "当用户想创建、查看、修改或删除交易所账户配置时调用。适用于用户提到交易所账户、API Key、Secret、Passphrase、测试网开关、启用状态等配置管理需求。不用于排查 invalid signature、timestamp、权限不足、白名单限制等连接或鉴权诊断问题。",
|
||||
"actions": {
|
||||
"create": {
|
||||
"description": "创建新的交易所配置。",
|
||||
"required_slots": ["exchange_type"],
|
||||
"optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "testnet"]
|
||||
},
|
||||
"update": {
|
||||
"description": "更新已有交易所配置。",
|
||||
"required_slots": ["target_ref"],
|
||||
"optional_slots": ["account_name", "api_key", "secret_key", "passphrase", "enabled", "testnet"]
|
||||
},
|
||||
"delete": {
|
||||
"description": "删除交易所配置。",
|
||||
"required_slots": ["target_ref"],
|
||||
"needs_confirmation": true
|
||||
},
|
||||
"query": {
|
||||
"description": "查询交易所配置。"
|
||||
}
|
||||
},
|
||||
"tool_mapping": {
|
||||
"create": "manage_exchange_config:create",
|
||||
"update": "manage_exchange_config:update",
|
||||
"delete": "manage_exchange_config:delete",
|
||||
"query": "get_exchange_configs"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "model_diagnosis",
|
||||
"kind": "diagnosis",
|
||||
"domain": "model",
|
||||
"description": "当用户反馈模型配置失败、API Key 无效、Base URL 非法、模型名不匹配、调用返回错误、模型不可用等问题时调用。适用于用户在接入或测试大模型时遇到的配置与兼容性故障。不用于创建、修改、删除或查询模型配置这类管理操作。"
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "model_management",
|
||||
"kind": "management",
|
||||
"domain": "model",
|
||||
"description": "当用户想创建、查看、修改或删除 AI 模型配置时调用。适用于用户提到 provider、API Key、Base URL、模型名称、启用状态等配置管理需求。不用于排查模型调用失败、接口不兼容、鉴权错误、模型不存在等诊断问题。",
|
||||
"actions": {
|
||||
"create": {
|
||||
"description": "创建新的模型配置。",
|
||||
"required_slots": ["provider"],
|
||||
"optional_slots": ["name", "api_key", "custom_api_url", "custom_model_name", "enabled"]
|
||||
},
|
||||
"update": {
|
||||
"description": "更新已有模型配置。",
|
||||
"required_slots": ["target_ref"],
|
||||
"optional_slots": ["api_key", "custom_api_url", "custom_model_name", "enabled"]
|
||||
},
|
||||
"delete": {
|
||||
"description": "删除模型配置。",
|
||||
"required_slots": ["target_ref"],
|
||||
"needs_confirmation": true
|
||||
},
|
||||
"query": {
|
||||
"description": "查询模型配置。"
|
||||
}
|
||||
},
|
||||
"tool_mapping": {
|
||||
"create": "manage_model_config:create",
|
||||
"update": "manage_model_config:update",
|
||||
"delete": "manage_model_config:delete",
|
||||
"query": "get_model_configs"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "strategy_diagnosis",
|
||||
"kind": "diagnosis",
|
||||
"domain": "strategy",
|
||||
"description": "当用户反馈策略未生效、策略输出异常、提示词或配置结果与预期不一致、策略执行表现异常时调用。适用于策略内容和执行效果相关的排障与解释。不用于创建、修改、删除、激活、复制或查询策略模板这类管理操作。"
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"name": "strategy_management",
|
||||
"kind": "management",
|
||||
"domain": "strategy",
|
||||
"description": "当用户想创建、查看、修改、删除、激活或复制策略模板时调用。适用于用户提到策略名称、策略配置、描述、语言、激活状态、复制新版本等管理需求。不用于排查策略未生效、策略输出异常、执行结果异常等诊断问题。",
|
||||
"actions": {
|
||||
"create": {
|
||||
"description": "创建策略模板。",
|
||||
"required_slots": ["name"],
|
||||
"optional_slots": ["config", "description", "lang"]
|
||||
},
|
||||
"update": {
|
||||
"description": "更新策略模板。",
|
||||
"required_slots": ["target_ref"],
|
||||
"optional_slots": ["name", "config", "description"]
|
||||
},
|
||||
"delete": {
|
||||
"description": "删除策略模板。",
|
||||
"required_slots": ["target_ref"],
|
||||
"needs_confirmation": true
|
||||
},
|
||||
"activate": {
|
||||
"description": "激活策略模板。",
|
||||
"required_slots": ["target_ref"]
|
||||
},
|
||||
"duplicate": {
|
||||
"description": "复制策略模板。",
|
||||
"required_slots": ["target_ref", "name"]
|
||||
},
|
||||
"query": {
|
||||
"description": "查询策略模板。"
|
||||
}
|
||||
},
|
||||
"tool_mapping": {
|
||||
"create": "manage_strategy:create",
|
||||
"update": "manage_strategy:update",
|
||||
"delete": "manage_strategy:delete",
|
||||
"activate": "manage_strategy:activate",
|
||||
"duplicate": "manage_strategy:duplicate",
|
||||
"query": "get_strategies"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "trader_diagnosis",
|
||||
"kind": "diagnosis",
|
||||
"domain": "trader",
|
||||
"description": "当用户反馈交易员无法启动、启动后不交易、绑定模型或交易所缺失、运行状态异常、收益或仓位表现异常时调用。适用于交易员运行过程中的排障与原因定位。不用于创建、修改、删除、启动、停止或查询交易员这类管理操作。"
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"name": "trader_management",
|
||||
"kind": "management",
|
||||
"domain": "trader",
|
||||
"description": "当用户想创建、查看、修改、删除、启动或停止交易员时调用。适用于用户提到交易员名称、绑定交易所、绑定模型、绑定策略、扫描频率、自定义提示词、运行状态等管理需求。不用于排查交易员启动失败、未下单、收益异常、仓位异常等诊断问题。",
|
||||
"intents": [
|
||||
"创建交易员",
|
||||
"修改交易员",
|
||||
"删除交易员",
|
||||
"启动交易员",
|
||||
"停止交易员",
|
||||
"查询交易员"
|
||||
],
|
||||
"actions": {
|
||||
"create": {
|
||||
"description": "创建新的交易员。",
|
||||
"required_slots": ["name", "exchange", "model"],
|
||||
"optional_slots": ["strategy", "auto_start"]
|
||||
},
|
||||
"update": {
|
||||
"description": "更新已有交易员。",
|
||||
"required_slots": ["target_ref"],
|
||||
"optional_slots": ["name", "exchange", "model", "strategy", "scan_interval_minutes", "custom_prompt"]
|
||||
},
|
||||
"delete": {
|
||||
"description": "删除交易员。",
|
||||
"required_slots": ["target_ref"],
|
||||
"needs_confirmation": true
|
||||
},
|
||||
"start": {
|
||||
"description": "启动交易员。",
|
||||
"required_slots": ["target_ref"],
|
||||
"needs_confirmation": true
|
||||
},
|
||||
"stop": {
|
||||
"description": "停止交易员。",
|
||||
"required_slots": ["target_ref"],
|
||||
"needs_confirmation": true
|
||||
},
|
||||
"query": {
|
||||
"description": "查询交易员列表或状态。"
|
||||
}
|
||||
},
|
||||
"tool_mapping": {
|
||||
"create": "manage_trader:create",
|
||||
"update": "manage_trader:update",
|
||||
"delete": "manage_trader:delete",
|
||||
"start": "manage_trader:start",
|
||||
"stop": "manage_trader:stop",
|
||||
"query": "manage_trader:list"
|
||||
}
|
||||
}
|
||||
444
agent/stock.go
444
agent/stock.go
@@ -1,444 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"nofx/safe"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
// stockHTTPClient is a shared HTTP client for stock API requests.
|
||||
// Reused across calls for connection pooling.
|
||||
var stockHTTPClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
MaxIdleConnsPerHost: 5,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
// StockQuote holds real-time stock data.
|
||||
type StockQuote struct {
|
||||
Name string
|
||||
Code string
|
||||
Market string // "A股", "港股", "美股"
|
||||
Currency string // "CNY", "HKD", "USD"
|
||||
Open float64
|
||||
PrevClose float64
|
||||
Price float64
|
||||
High float64
|
||||
Low float64
|
||||
Volume float64
|
||||
Turnover float64
|
||||
Date string
|
||||
Time string
|
||||
Change float64
|
||||
ChangePct float64
|
||||
// 盘前盘后 (美股)
|
||||
ExtPrice float64 // 盘前/盘后价格
|
||||
ExtChangePct float64 // 盘前/盘后涨跌幅%
|
||||
ExtChange float64 // 盘前/盘后涨跌额
|
||||
ExtTime string // 盘前/盘后时间
|
||||
IsExtHours bool // 是否在盘前盘后时段
|
||||
}
|
||||
|
||||
// knownStocks maps Chinese names to stock codes.
|
||||
var knownStocks = map[string]string{
|
||||
// A股
|
||||
"拓维信息": "sz002261", "比亚迪": "sz002594", "宁德时代": "sz300750",
|
||||
"贵州茅台": "sh600519", "中国平安": "sh601318", "招商银行": "sh600036",
|
||||
"中芯国际": "sh688981", "工商银行": "sh601398", "建设银行": "sh601939",
|
||||
"中国银行": "sh601988", "农业银行": "sh601288", "中信证券": "sh600030",
|
||||
"海康威视": "sz002415", "立讯精密": "sz002475", "东方财富": "sz300059",
|
||||
"隆基绿能": "sh601012", "长城汽车": "sh601633", "科大讯飞": "sz002230",
|
||||
"三六零": "sh601360", "中兴通讯": "sz000063",
|
||||
// 港股
|
||||
"腾讯": "hk00700", "阿里巴巴": "hk09988", "美团": "hk03690",
|
||||
"小米": "hk01810", "京东": "hk09618", "网易": "hk09999",
|
||||
"百度": "hk09888", "快手": "hk01024", "哔哩哔哩": "hk09626",
|
||||
"理想汽车": "hk02015", "蔚来": "hk09866", "小鹏汽车": "hk09868",
|
||||
// 华为 is not publicly listed — removed incorrect Tencent fallback
|
||||
// 美股
|
||||
"苹果": "gb_aapl", "特斯拉": "gb_tsla", "英伟达": "gb_nvda",
|
||||
"微软": "gb_msft", "谷歌": "gb_googl", "亚马逊": "gb_amzn",
|
||||
"meta": "gb_meta", "奈飞": "gb_nflx", "台积电": "gb_tsm",
|
||||
"拼多多": "gb_pdd", "蔚来汽车": "gb_nio",
|
||||
}
|
||||
|
||||
// US stock ticker mapping
|
||||
var usTickerMap = map[string]string{
|
||||
"AAPL": "gb_aapl", "TSLA": "gb_tsla", "NVDA": "gb_nvda", "MSFT": "gb_msft",
|
||||
"GOOGL": "gb_googl", "AMZN": "gb_amzn", "META": "gb_meta", "NFLX": "gb_nflx",
|
||||
"TSM": "gb_tsm", "PDD": "gb_pdd", "NIO": "gb_nio", "BABA": "gb_baba",
|
||||
"JD": "gb_jd", "BIDU": "gb_bidu", "AMD": "gb_amd", "INTC": "gb_intc",
|
||||
"COIN": "gb_coin", "MARA": "gb_mara", "RIOT": "gb_riot",
|
||||
}
|
||||
|
||||
func resolveStockCode(text string) (string, string) {
|
||||
// Known Chinese names
|
||||
for name, code := range knownStocks {
|
||||
if strings.Contains(text, name) {
|
||||
return code, name
|
||||
}
|
||||
}
|
||||
|
||||
// US ticker symbols (uppercase)
|
||||
upper := strings.ToUpper(text)
|
||||
for ticker, code := range usTickerMap {
|
||||
if strings.Contains(upper, ticker) {
|
||||
return code, ticker
|
||||
}
|
||||
}
|
||||
|
||||
// 6-digit A-share code
|
||||
for _, w := range strings.Fields(text) {
|
||||
w = strings.TrimSpace(w)
|
||||
if len(w) == 6 {
|
||||
if _, err := strconv.Atoi(w); err == nil {
|
||||
prefix := "sz"
|
||||
if w[0] == '6' || w[0] == '9' { prefix = "sh" }
|
||||
return prefix + w, w
|
||||
}
|
||||
}
|
||||
// 5-digit HK code
|
||||
if len(w) == 5 {
|
||||
if _, err := strconv.Atoi(w); err == nil {
|
||||
return "hk" + w, w
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// SearchResult represents a stock search result from Sina suggest API.
|
||||
type SearchResult struct {
|
||||
Name string // Display name
|
||||
Code string // Sina-style code (e.g. sz300750, hk00700, gb_tsla)
|
||||
Ticker string // Raw ticker (e.g. 300750, 00700, tsla)
|
||||
Type string // Market type code: 11=A股, 31=港股, 41=美股
|
||||
Market string // "A股", "港股", "美股"
|
||||
}
|
||||
|
||||
// searchStock queries Sina's suggest API for dynamic stock search.
|
||||
// Returns matching stocks across A-share, HK, and US markets.
|
||||
func searchStock(keyword string) ([]SearchResult, error) {
|
||||
// type=11 (A股), 31 (港股), 41 (美股)
|
||||
u := fmt.Sprintf("https://suggest3.sinajs.cn/suggest/type=11,31,41&key=%s&name=suggestdata",
|
||||
url.QueryEscape(keyword))
|
||||
|
||||
req, _ := http.NewRequest("GET", u, nil)
|
||||
req.Header.Set("Referer", "https://finance.sina.com.cn")
|
||||
|
||||
resp, err := stockHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("stock search API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder())
|
||||
body, err := safe.ReadAllLimited(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
line := string(body)
|
||||
// Parse: var suggestdata="item1;item2;..."
|
||||
start := strings.Index(line, "\"")
|
||||
end := strings.LastIndex(line, "\"")
|
||||
if start == -1 || end <= start {
|
||||
return nil, fmt.Errorf("invalid suggest response")
|
||||
}
|
||||
data := line[start+1 : end]
|
||||
if data == "" {
|
||||
return nil, nil // no results
|
||||
}
|
||||
|
||||
var results []SearchResult
|
||||
items := strings.Split(data, ";")
|
||||
for _, item := range items {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(item, ",")
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
// fields: [0]=name, [1]=type, [2]=ticker, [3]=sinaCode, [4]=displayName
|
||||
typeCode := fields[1]
|
||||
ticker := fields[2]
|
||||
sinaCode := fields[3]
|
||||
displayName := fields[4]
|
||||
if displayName == "" {
|
||||
displayName = fields[0]
|
||||
}
|
||||
|
||||
var mkt, code string
|
||||
switch typeCode {
|
||||
case "11": // A股
|
||||
mkt = "A股"
|
||||
code = sinaCode // already like sz300750, sh600519
|
||||
if code == "" {
|
||||
// Build from ticker
|
||||
prefix := "sz"
|
||||
if len(ticker) == 6 && (ticker[0] == '6' || ticker[0] == '9') {
|
||||
prefix = "sh"
|
||||
}
|
||||
code = prefix + ticker
|
||||
}
|
||||
case "31": // 港股
|
||||
mkt = "港股"
|
||||
code = "hk" + ticker
|
||||
case "41": // 美股
|
||||
mkt = "美股"
|
||||
code = "gb_" + ticker
|
||||
default:
|
||||
continue // skip funds (201), indices, etc.
|
||||
}
|
||||
|
||||
results = append(results, SearchResult{
|
||||
Name: displayName,
|
||||
Code: code,
|
||||
Ticker: ticker,
|
||||
Type: typeCode,
|
||||
Market: mkt,
|
||||
})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// resolveStockCodeDynamic tries local map first, then falls back to Sina search API.
|
||||
func resolveStockCodeDynamic(text string) (string, string) {
|
||||
// First try the static map
|
||||
code, name := resolveStockCode(text)
|
||||
if code != "" {
|
||||
return code, name
|
||||
}
|
||||
|
||||
// Fall back to Sina search API
|
||||
// Extract a meaningful search keyword from the text
|
||||
keyword := extractStockKeyword(text)
|
||||
if keyword == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
results, err := searchStock(keyword)
|
||||
if err != nil || len(results) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Return the first (best) result
|
||||
return results[0].Code, results[0].Name
|
||||
}
|
||||
|
||||
// extractStockKeyword extracts a likely stock name/ticker from user text.
|
||||
func extractStockKeyword(text string) string {
|
||||
// Remove common prefixes/suffixes that aren't stock names
|
||||
text = strings.TrimSpace(text)
|
||||
|
||||
// If the text itself is short enough, use it directly
|
||||
// (e.g. "中远海控" or "AAPL")
|
||||
if len([]rune(text)) <= 10 {
|
||||
return text
|
||||
}
|
||||
|
||||
// Try to extract quoted terms first: 「xxx」 or "xxx"
|
||||
quotePairs := [][2]string{
|
||||
{"「", "」"},
|
||||
{"\u201c", "\u201d"},
|
||||
{"\u2018", "\u2019"},
|
||||
{"\"", "\""},
|
||||
}
|
||||
for _, pair := range quotePairs {
|
||||
if s := strings.Index(text, pair[0]); s >= 0 {
|
||||
if e := strings.Index(text[s+len(pair[0]):], pair[1]); e >= 0 {
|
||||
return text[s+len(pair[0]) : s+len(pair[0])+e]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for patterns like "查 XXX", "搜索 XXX", "查一下 XXX"
|
||||
for _, prefix := range []string{"查一下", "搜索", "查询", "看看", "搜一下", "查", "看", "search ", "find "} {
|
||||
if idx := strings.Index(text, prefix); idx >= 0 {
|
||||
rest := strings.TrimSpace(text[idx+len(prefix):])
|
||||
// Take the first "word" (either Chinese characters or English word)
|
||||
words := strings.Fields(rest)
|
||||
if len(words) > 0 {
|
||||
return words[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: use first few words
|
||||
words := strings.Fields(text)
|
||||
if len(words) > 0 {
|
||||
return words[0]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchStockQuote(code string) (*StockQuote, error) {
|
||||
url := fmt.Sprintf("https://hq.sinajs.cn/list=%s", code)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Referer", "https://finance.sina.com.cn")
|
||||
|
||||
resp, err := stockHTTPClient.Do(req)
|
||||
if err != nil { return nil, err }
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("stock quote API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
reader := transform.NewReader(io.LimitReader(resp.Body, 256*1024), simplifiedchinese.GBK.NewDecoder())
|
||||
body, err := safe.ReadAllLimited(reader)
|
||||
if err != nil { return nil, err }
|
||||
|
||||
line := string(body)
|
||||
start := strings.Index(line, "\"")
|
||||
end := strings.LastIndex(line, "\"")
|
||||
if start == -1 || end <= start { return nil, fmt.Errorf("invalid response") }
|
||||
|
||||
data := line[start+1 : end]
|
||||
if data == "" { return nil, fmt.Errorf("empty data for %s", code) }
|
||||
|
||||
if strings.HasPrefix(code, "sh") || strings.HasPrefix(code, "sz") {
|
||||
return parseAShare(code, data)
|
||||
} else if strings.HasPrefix(code, "hk") {
|
||||
return parseHKShare(code, data)
|
||||
} else if strings.HasPrefix(code, "gb_") {
|
||||
return parseUSShare(code, data)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported market: %s", code)
|
||||
}
|
||||
|
||||
func parseAShare(code, data string) (*StockQuote, error) {
|
||||
f := strings.Split(data, ",")
|
||||
if len(f) < 32 { return nil, fmt.Errorf("too few fields") }
|
||||
|
||||
q := &StockQuote{Name: f[0], Code: code, Market: "A股", Currency: "CNY"}
|
||||
q.Open, _ = strconv.ParseFloat(f[1], 64)
|
||||
q.PrevClose, _ = strconv.ParseFloat(f[2], 64)
|
||||
q.Price, _ = strconv.ParseFloat(f[3], 64)
|
||||
q.High, _ = strconv.ParseFloat(f[4], 64)
|
||||
q.Low, _ = strconv.ParseFloat(f[5], 64)
|
||||
q.Volume, _ = strconv.ParseFloat(f[8], 64)
|
||||
q.Turnover, _ = strconv.ParseFloat(f[9], 64)
|
||||
q.Date = f[30]; q.Time = f[31]
|
||||
if q.PrevClose > 0 { q.Change = q.Price - q.PrevClose; q.ChangePct = (q.Change / q.PrevClose) * 100 }
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func parseHKShare(code, data string) (*StockQuote, error) {
|
||||
f := strings.Split(data, ",")
|
||||
if len(f) < 18 { return nil, fmt.Errorf("too few fields") }
|
||||
|
||||
q := &StockQuote{Name: f[1], Code: code, Market: "港股", Currency: "HKD"}
|
||||
q.PrevClose, _ = strconv.ParseFloat(f[3], 64)
|
||||
q.Open, _ = strconv.ParseFloat(f[2], 64)
|
||||
q.High, _ = strconv.ParseFloat(f[4], 64)
|
||||
q.Low, _ = strconv.ParseFloat(f[5], 64)
|
||||
q.Price, _ = strconv.ParseFloat(f[6], 64)
|
||||
q.Change, _ = strconv.ParseFloat(f[7], 64)
|
||||
q.ChangePct, _ = strconv.ParseFloat(f[8], 64)
|
||||
q.Turnover, _ = strconv.ParseFloat(f[10], 64)
|
||||
q.Volume, _ = strconv.ParseFloat(f[11], 64)
|
||||
if len(f) > 17 { q.Date = f[17]; q.Time = f[17] }
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func parseUSShare(code, data string) (*StockQuote, error) {
|
||||
f := strings.Split(data, ",")
|
||||
if len(f) < 30 { return nil, fmt.Errorf("too few fields") }
|
||||
|
||||
q := &StockQuote{Name: f[0], Code: code, Market: "美股", Currency: "USD"}
|
||||
q.Price, _ = strconv.ParseFloat(f[1], 64)
|
||||
q.ChangePct, _ = strconv.ParseFloat(f[2], 64)
|
||||
q.Change, _ = strconv.ParseFloat(f[4], 64)
|
||||
q.Open, _ = strconv.ParseFloat(f[5], 64)
|
||||
q.High, _ = strconv.ParseFloat(f[6], 64)
|
||||
q.Low, _ = strconv.ParseFloat(f[7], 64)
|
||||
// 52wk high/low
|
||||
high52, _ := strconv.ParseFloat(f[8], 64)
|
||||
low52, _ := strconv.ParseFloat(f[9], 64)
|
||||
q.Volume, _ = strconv.ParseFloat(f[10], 64)
|
||||
q.Turnover, _ = strconv.ParseFloat(f[11], 64)
|
||||
if len(f) > 25 { q.Date = f[25]; q.Time = f[26] }
|
||||
q.PrevClose = q.Price - q.Change
|
||||
_ = high52; _ = low52
|
||||
|
||||
// 盘前盘后数据 (字段21=价格, 22=涨跌幅%, 23=涨跌额, 24=时间)
|
||||
if len(f) > 24 {
|
||||
extPrice, _ := strconv.ParseFloat(f[21], 64)
|
||||
extPct, _ := strconv.ParseFloat(f[22], 64)
|
||||
extChg, _ := strconv.ParseFloat(f[23], 64)
|
||||
if extPrice > 0 {
|
||||
q.ExtPrice = extPrice
|
||||
q.ExtChangePct = extPct
|
||||
q.ExtChange = extChg
|
||||
q.ExtTime = strings.TrimSpace(f[24])
|
||||
q.IsExtHours = true
|
||||
}
|
||||
}
|
||||
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func formatStockQuote(q *StockQuote) string {
|
||||
emoji := "🟢"
|
||||
if q.ChangePct < 0 { emoji = "🔴" }
|
||||
|
||||
sym := "¥"
|
||||
if q.Currency == "USD" { sym = "$" }
|
||||
if q.Currency == "HKD" { sym = "HK$" }
|
||||
|
||||
volStr := fmt.Sprintf("%.0f", q.Volume)
|
||||
if q.Volume > 1000000 { volStr = fmt.Sprintf("%.1f万", q.Volume/10000) }
|
||||
if q.Volume > 100000000 { volStr = fmt.Sprintf("%.2f亿", q.Volume/100000000) }
|
||||
|
||||
turnStr := fmt.Sprintf("%.0f", q.Turnover)
|
||||
if q.Turnover > 100000000 { turnStr = fmt.Sprintf("%.2f亿", q.Turnover/100000000) }
|
||||
|
||||
result := fmt.Sprintf(`%s *%s* (%s · %s)
|
||||
💰 现价: %s%.2f (%+.2f%%)
|
||||
📊 开盘: %s%.2f | 昨收: %s%.2f
|
||||
📈 最高: %s%.2f | 最低: %s%.2f
|
||||
📦 成交: %s | 额: %s
|
||||
🕐 %s`,
|
||||
emoji, q.Name, q.Code, q.Market,
|
||||
sym, q.Price, q.ChangePct,
|
||||
sym, q.Open, sym, q.PrevClose,
|
||||
sym, q.High, sym, q.Low,
|
||||
volStr, turnStr,
|
||||
q.Date)
|
||||
|
||||
// 盘前盘后数据
|
||||
if q.IsExtHours && q.ExtPrice > 0 {
|
||||
extEmoji := "🟢"
|
||||
if q.ExtChangePct < 0 { extEmoji = "🔴" }
|
||||
extLabel := "🌙 盘后"
|
||||
if strings.Contains(strings.ToLower(q.ExtTime), "am") {
|
||||
extLabel = "🌅 盘前"
|
||||
}
|
||||
result += fmt.Sprintf("\n%s %s: %s%.2f (%+.2f%%) %s",
|
||||
extLabel, extEmoji, sym, q.ExtPrice, q.ExtChangePct, q.ExtTime)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
2242
agent/tools.go
2242
agent/tools.go
File diff suppressed because it is too large
Load Diff
@@ -1,65 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsStockSymbol(t *testing.T) {
|
||||
tests := []struct {
|
||||
sym string
|
||||
want bool
|
||||
}{
|
||||
// Known crypto base symbols — must NOT be detected as stock
|
||||
{"BTC", false},
|
||||
{"ETH", false},
|
||||
{"SOL", false},
|
||||
{"BNB", false},
|
||||
{"XRP", false},
|
||||
{"DOGE", false},
|
||||
{"ADA", false},
|
||||
{"AVAX", false},
|
||||
{"DOT", false},
|
||||
{"LINK", false},
|
||||
{"PEPE", false},
|
||||
{"SHIB", false},
|
||||
{"TRUMP", false},
|
||||
{"USDT", false},
|
||||
{"USDC", false},
|
||||
{"W", false}, // single letter crypto
|
||||
|
||||
// Crypto pairs — must NOT be stock
|
||||
{"BTCUSDT", false},
|
||||
{"ETHUSDT", false},
|
||||
{"SOLUSDT", false},
|
||||
{"DOGEUSDT", false},
|
||||
|
||||
// Real stock tickers — must be detected as stock
|
||||
{"AAPL", true},
|
||||
{"TSLA", true},
|
||||
{"NVDA", true},
|
||||
{"MSFT", true},
|
||||
{"GOOGL", true},
|
||||
{"AMZN", true},
|
||||
{"META", true},
|
||||
{"AMD", true},
|
||||
{"PLTR", true},
|
||||
{"BA", true},
|
||||
{"F", true}, // Ford — 1 letter
|
||||
{"GM", true}, // 2 letters
|
||||
{"JPM", true}, // 3 letters
|
||||
|
||||
// Mixed / edge cases
|
||||
{"btc", false}, // lowercase crypto
|
||||
{"aapl", true}, // lowercase stock (uppercased internally)
|
||||
{"BTC123", false}, // not pure letters
|
||||
{"123456", false}, // digits
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.sym, func(t *testing.T) {
|
||||
got := isStockSymbol(tt.sym)
|
||||
if got != tt.want {
|
||||
t.Errorf("isStockSymbol(%q) = %v, want %v", tt.sym, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
356
agent/trade.go
356
agent/trade.go
@@ -1,356 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TradeAction represents a parsed trade intent from the LLM or user.
|
||||
type TradeAction struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short"
|
||||
Symbol string `json:"symbol"` // e.g. "BTCUSDT"
|
||||
Quantity float64 `json:"quantity"` // amount
|
||||
Leverage int `json:"leverage"` // leverage multiplier
|
||||
TraderID string `json:"trader_id"` // which trader to use
|
||||
Status string `json:"status"` // "pending", "confirmed", "executed", "failed", "expired"
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// pendingTrades stores pending trade confirmations.
|
||||
type pendingTrades struct {
|
||||
mu sync.RWMutex
|
||||
trades map[string]*TradeAction // id -> trade
|
||||
}
|
||||
|
||||
func newPendingTrades() *pendingTrades {
|
||||
return &pendingTrades{trades: make(map[string]*TradeAction)}
|
||||
}
|
||||
|
||||
func (p *pendingTrades) Add(t *TradeAction) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.trades[t.ID] = t
|
||||
}
|
||||
|
||||
func (p *pendingTrades) Get(id string) *TradeAction {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.trades[id]
|
||||
}
|
||||
|
||||
func (p *pendingTrades) Remove(id string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
delete(p.trades, id)
|
||||
}
|
||||
|
||||
// CleanExpired removes trades older than 5 minutes.
|
||||
func (p *pendingTrades) CleanExpired() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
cutoff := time.Now().Add(-5 * time.Minute).Unix()
|
||||
for id, t := range p.trades {
|
||||
if t.CreatedAt < cutoff {
|
||||
delete(p.trades, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseTradeCommand parses natural language trade commands.
|
||||
// Returns nil if the message is not a trade command.
|
||||
func parseTradeCommand(text string) *TradeAction {
|
||||
upper := strings.ToUpper(strings.TrimSpace(text))
|
||||
|
||||
// Pattern: "做多 BTC 0.01" / "做空 ETH 0.1" / "long BTC 0.01" / "short ETH 0.1"
|
||||
// Also: "平多 BTC" / "平空 ETH" / "close long BTC" / "close short ETH"
|
||||
|
||||
var action, symbol string
|
||||
var quantity float64
|
||||
var leverage int
|
||||
|
||||
words := strings.Fields(upper)
|
||||
if len(words) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch words[0] {
|
||||
case "做多", "LONG", "BUY":
|
||||
action = "open_long"
|
||||
case "做空", "SHORT", "SELL":
|
||||
action = "open_short"
|
||||
case "平多":
|
||||
action = "close_long"
|
||||
case "平空":
|
||||
action = "close_short"
|
||||
case "CLOSE":
|
||||
if len(words) >= 3 {
|
||||
switch words[1] {
|
||||
case "LONG":
|
||||
action = "close_long"
|
||||
words = append(words[:1], words[2:]...) // remove "LONG"
|
||||
case "SHORT":
|
||||
action = "close_short"
|
||||
words = append(words[:1], words[2:]...) // remove "SHORT"
|
||||
}
|
||||
}
|
||||
if action == "" {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse symbol
|
||||
if len(words) < 2 {
|
||||
return nil
|
||||
}
|
||||
symbol = words[1]
|
||||
// Only append USDT for crypto symbols, not stock tickers
|
||||
if !isStockSymbol(symbol) && !strings.HasSuffix(symbol, "USDT") {
|
||||
symbol += "USDT"
|
||||
}
|
||||
|
||||
// Parse quantity (optional)
|
||||
if len(words) >= 3 {
|
||||
fmt.Sscanf(words[2], "%f", &quantity)
|
||||
}
|
||||
|
||||
// Parse leverage (optional, "x10" or "10x")
|
||||
if len(words) >= 4 {
|
||||
lev := strings.TrimSuffix(strings.TrimPrefix(words[3], "X"), "X")
|
||||
fmt.Sscanf(lev, "%d", &leverage)
|
||||
}
|
||||
|
||||
if action == "" || symbol == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &TradeAction{
|
||||
ID: fmt.Sprintf("trade_%d", time.Now().UnixNano()),
|
||||
Action: action,
|
||||
Symbol: symbol,
|
||||
Quantity: quantity,
|
||||
Leverage: leverage,
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// executeTrade performs the actual trade execution via TraderManager.
|
||||
func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error {
|
||||
if a.traderManager == nil {
|
||||
return fmt.Errorf("no trader manager available")
|
||||
}
|
||||
|
||||
traders := a.traderManager.GetAllTraders()
|
||||
if len(traders) == 0 {
|
||||
return fmt.Errorf("no traders configured")
|
||||
}
|
||||
|
||||
// Determine if this is a stock trade to route to the right exchange
|
||||
wantStock := isStockSymbol(trade.Symbol)
|
||||
|
||||
// Find a running trader's underlying exchange interface
|
||||
var underlyingTrader interface {
|
||||
OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
|
||||
OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error)
|
||||
CloseLong(symbol string, quantity float64) (map[string]interface{}, error)
|
||||
CloseShort(symbol string, quantity float64) (map[string]interface{}, error)
|
||||
}
|
||||
|
||||
for _, t := range traders {
|
||||
s := t.GetStatus()
|
||||
running, _ := s["is_running"].(bool)
|
||||
if running {
|
||||
ut := t.GetUnderlyingTrader()
|
||||
if ut == nil {
|
||||
continue
|
||||
}
|
||||
// Route stock symbols to alpaca traders, crypto to others
|
||||
exchange := t.GetExchange()
|
||||
isAlpaca := exchange == "alpaca"
|
||||
if wantStock && !isAlpaca {
|
||||
continue // Skip non-stock traders for stock symbols
|
||||
}
|
||||
if !wantStock && isAlpaca {
|
||||
continue // Skip stock traders for crypto symbols
|
||||
}
|
||||
underlyingTrader = ut
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if underlyingTrader == nil {
|
||||
if wantStock {
|
||||
return fmt.Errorf("no running stock trader (Alpaca) found — configure one to trade stocks")
|
||||
}
|
||||
return fmt.Errorf("no running trader supports trade execution")
|
||||
}
|
||||
|
||||
// Sanity caps to prevent LLM hallucinations or input errors from causing damage.
|
||||
const maxQuantity = 100000.0
|
||||
const maxLeverage = 125
|
||||
|
||||
if trade.Leverage > maxLeverage {
|
||||
return fmt.Errorf("leverage %dx exceeds maximum allowed (%dx)", trade.Leverage, maxLeverage)
|
||||
}
|
||||
|
||||
switch trade.Action {
|
||||
case "open_long":
|
||||
if trade.Quantity <= 0 {
|
||||
return fmt.Errorf("quantity must be > 0")
|
||||
}
|
||||
if trade.Quantity > maxQuantity {
|
||||
return fmt.Errorf("quantity %.4f exceeds maximum allowed (%.0f)", trade.Quantity, maxQuantity)
|
||||
}
|
||||
_, err := underlyingTrader.OpenLong(trade.Symbol, trade.Quantity, trade.Leverage)
|
||||
return err
|
||||
case "open_short":
|
||||
if trade.Quantity <= 0 {
|
||||
return fmt.Errorf("quantity must be > 0")
|
||||
}
|
||||
if trade.Quantity > maxQuantity {
|
||||
return fmt.Errorf("quantity %.4f exceeds maximum allowed (%.0f)", trade.Quantity, maxQuantity)
|
||||
}
|
||||
_, err := underlyingTrader.OpenShort(trade.Symbol, trade.Quantity, trade.Leverage)
|
||||
return err
|
||||
case "close_long":
|
||||
_, err := underlyingTrader.CloseLong(trade.Symbol, trade.Quantity)
|
||||
return err
|
||||
case "close_short":
|
||||
_, err := underlyingTrader.CloseShort(trade.Symbol, trade.Quantity)
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("unknown action: %s", trade.Action)
|
||||
}
|
||||
}
|
||||
|
||||
// formatTradeConfirmation creates a confirmation message for a pending trade.
|
||||
func formatTradeConfirmation(trade *TradeAction, lang string) string {
|
||||
actionNames := map[string]string{
|
||||
"open_long": "做多 (Long)",
|
||||
"open_short": "做空 (Short)",
|
||||
"close_long": "平多 (Close Long)",
|
||||
"close_short": "平空 (Close Short)",
|
||||
}
|
||||
|
||||
symbol := trade.Symbol
|
||||
if strings.HasSuffix(symbol, "USDT") {
|
||||
symbol = strings.TrimSuffix(symbol, "USDT")
|
||||
}
|
||||
actionName := actionNames[trade.Action]
|
||||
if actionName == "" {
|
||||
actionName = trade.Action
|
||||
}
|
||||
|
||||
if lang == "zh" {
|
||||
msg := fmt.Sprintf("⚠️ **交易确认**\n\n"+
|
||||
"操作: %s\n"+
|
||||
"品种: %s\n", actionName, symbol)
|
||||
if trade.Quantity > 0 {
|
||||
msg += fmt.Sprintf("数量: %.4f\n", trade.Quantity)
|
||||
}
|
||||
if trade.Leverage > 0 {
|
||||
msg += fmt.Sprintf("杠杆: %dx\n", trade.Leverage)
|
||||
}
|
||||
msg += fmt.Sprintf("\n发送 `确认 %s` 执行交易,或忽略取消。", trade.ID)
|
||||
return msg
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("⚠️ **Trade Confirmation**\n\n"+
|
||||
"Action: %s\n"+
|
||||
"Symbol: %s\n", actionName, symbol)
|
||||
if trade.Quantity > 0 {
|
||||
msg += fmt.Sprintf("Quantity: %.4f\n", trade.Quantity)
|
||||
}
|
||||
if trade.Leverage > 0 {
|
||||
msg += fmt.Sprintf("Leverage: %dx\n", trade.Leverage)
|
||||
}
|
||||
msg += fmt.Sprintf("\nSend `confirm %s` to execute, or ignore to cancel.", trade.ID)
|
||||
return msg
|
||||
}
|
||||
|
||||
// handleTradeConfirmation processes a trade confirmation message.
|
||||
func (a *Agent) handleTradeConfirmation(ctx context.Context, userID int64, text, lang string) (string, bool) {
|
||||
upper := strings.ToUpper(strings.TrimSpace(text))
|
||||
|
||||
var tradeID string
|
||||
if strings.HasPrefix(upper, "确认 ") || strings.HasPrefix(upper, "CONFIRM ") {
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) >= 2 {
|
||||
tradeID = parts[1]
|
||||
}
|
||||
}
|
||||
|
||||
if tradeID == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if a.pending == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
trade := a.pending.Get(tradeID)
|
||||
if trade == nil {
|
||||
if lang == "zh" {
|
||||
return "❌ 交易已过期或不存在。", true
|
||||
}
|
||||
return "❌ Trade expired or not found.", true
|
||||
}
|
||||
|
||||
a.pending.Remove(tradeID)
|
||||
trade.Status = "confirmed"
|
||||
|
||||
a.logger.Info("executing trade",
|
||||
slog.String("id", trade.ID),
|
||||
slog.String("action", trade.Action),
|
||||
slog.String("symbol", trade.Symbol),
|
||||
slog.Float64("quantity", trade.Quantity),
|
||||
)
|
||||
|
||||
err := a.executeTrade(ctx, trade)
|
||||
if err != nil {
|
||||
trade.Status = "failed"
|
||||
trade.Error = err.Error()
|
||||
if lang == "zh" {
|
||||
return fmt.Sprintf("❌ 交易执行失败: %s", err.Error()), true
|
||||
}
|
||||
return fmt.Sprintf("❌ Trade execution failed: %s", err.Error()), true
|
||||
}
|
||||
|
||||
trade.Status = "executed"
|
||||
symbol := trade.Symbol
|
||||
if strings.HasSuffix(symbol, "USDT") {
|
||||
symbol = strings.TrimSuffix(symbol, "USDT")
|
||||
}
|
||||
actionEmoji := "📈"
|
||||
if strings.Contains(trade.Action, "short") {
|
||||
actionEmoji = "📉"
|
||||
}
|
||||
if strings.Contains(trade.Action, "close") {
|
||||
actionEmoji = "✅"
|
||||
}
|
||||
|
||||
qtyStr := ""
|
||||
if trade.Quantity > 0 {
|
||||
qtyStr = fmt.Sprintf(" %.4f", trade.Quantity)
|
||||
}
|
||||
|
||||
if lang == "zh" {
|
||||
return fmt.Sprintf("%s 交易已执行!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true
|
||||
}
|
||||
return fmt.Sprintf("%s Trade executed!\n%s %s%s", actionEmoji, trade.Action, symbol, qtyStr), true
|
||||
}
|
||||
|
||||
// marshals trade action to JSON for embedding in responses
|
||||
func marshalTradeAction(trade *TradeAction) string {
|
||||
b, _ := json.Marshal(trade)
|
||||
return string(b)
|
||||
}
|
||||
343
agent/web.go
343
agent/web.go
@@ -1,343 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"nofx/safe"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
type storeUserIDContextKey struct{}
|
||||
|
||||
// WithStoreUserID annotates an HTTP request context with the authenticated store user ID.
|
||||
func WithStoreUserID(ctx context.Context, storeUserID string) context.Context {
|
||||
return context.WithValue(ctx, storeUserIDContextKey{}, storeUserID)
|
||||
}
|
||||
|
||||
func storeUserIDFromContext(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(storeUserIDContextKey{}).(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
// validSymbolRe matches only alphanumeric trading symbols (e.g. BTCUSDT, ETH-USD).
|
||||
var validSymbolRe = regexp.MustCompile(`^[A-Za-z0-9\-_]{1,20}$`)
|
||||
|
||||
// validIntervalRe matches only valid kline intervals (e.g. 1m, 5m, 1h, 4h, 1d, 1w).
|
||||
var validIntervalRe = regexp.MustCompile(`^[0-9]{1,2}[mhHdDwWM]$`)
|
||||
|
||||
// binanceClient is a shared HTTP client for proxying Binance API requests.
|
||||
// Reused across requests to benefit from connection pooling.
|
||||
var binanceClient = &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 20,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
// WebHandler provides HTTP endpoints for the NOFXi agent.
|
||||
type WebHandler struct {
|
||||
agent *Agent
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewWebHandler(agent *Agent, logger *slog.Logger) *WebHandler {
|
||||
return &WebHandler{agent: agent, logger: logger}
|
||||
}
|
||||
|
||||
// HandleHealth handles GET /api/agent/health.
|
||||
func (w *WebHandler) HandleHealth(rw http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(rw, 200, map[string]string{"status": "ok", "agent": "NOFXi", "time": time.Now().Format(time.RFC3339)})
|
||||
}
|
||||
|
||||
// HandleChat handles POST /api/agent/chat.
|
||||
func (w *WebHandler) HandleChat(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(rw, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserKey string `json:"user_key"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
// Limit request body to 64KB to prevent abuse
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
|
||||
writeJSON(rw, 400, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if req.Message == "" {
|
||||
writeJSON(rw, 400, map[string]string{"error": "message required"})
|
||||
return
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = SessionUserIDFromKey(req.UserKey)
|
||||
}
|
||||
msg := req.Message
|
||||
if req.Lang != "" {
|
||||
msg = "[lang:" + req.Lang + "] " + msg
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 55*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := w.agent.HandleMessageForStoreUser(ctx, storeUserIDFromContext(r.Context()), req.UserID, msg)
|
||||
if err != nil {
|
||||
w.logger.Error("agent HandleMessage failed", "error", err, "user_id", req.UserID)
|
||||
writeJSON(rw, 500, map[string]string{"error": "Failed to process message. Please try again."})
|
||||
return
|
||||
}
|
||||
writeJSON(rw, 200, map[string]string{"response": resp})
|
||||
}
|
||||
|
||||
// HandleChatStream handles POST /api/agent/chat/stream — SSE streaming chat.
|
||||
// Sends server-sent events with types including planning, plan, step_start,
|
||||
// step_complete, replan, tool, delta, done, error.
|
||||
func (w *WebHandler) HandleChatStream(rw http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(rw, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
UserID int64 `json:"user_id"`
|
||||
UserKey string `json:"user_key"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
|
||||
writeJSON(rw, 400, map[string]string{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if req.Message == "" {
|
||||
writeJSON(rw, 400, map[string]string{"error": "message required"})
|
||||
return
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
req.UserID = SessionUserIDFromKey(req.UserKey)
|
||||
}
|
||||
msg := req.Message
|
||||
if req.Lang != "" {
|
||||
msg = "[lang:" + req.Lang + "] " + msg
|
||||
}
|
||||
|
||||
// Set SSE headers
|
||||
rw.Header().Set("Content-Type", "text/event-stream")
|
||||
rw.Header().Set("Cache-Control", "no-cache")
|
||||
rw.Header().Set("Connection", "keep-alive")
|
||||
rw.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering
|
||||
rw.WriteHeader(200)
|
||||
|
||||
flusher, ok := rw.(http.Flusher)
|
||||
if !ok {
|
||||
writeSSE(rw, nil, "error", "streaming not supported")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := w.agent.HandleMessageStreamForStoreUser(ctx, storeUserIDFromContext(r.Context()), req.UserID, msg, func(event, data string) {
|
||||
writeSSE(rw, flusher, event, data)
|
||||
})
|
||||
if err != nil {
|
||||
w.logger.Error("agent HandleMessageStream failed", "error", err, "user_id", req.UserID)
|
||||
writeSSE(rw, flusher, "error", "Failed to process message. Please try again.")
|
||||
return
|
||||
}
|
||||
// Send final done event with complete response
|
||||
writeSSE(rw, flusher, "done", resp)
|
||||
}
|
||||
|
||||
// writeSSE writes a single SSE event.
|
||||
func writeSSE(w http.ResponseWriter, flusher http.Flusher, event, data string) {
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, sseEscape(data))
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// sseEscape escapes newlines in SSE data (each line needs a "data: " prefix).
|
||||
func sseEscape(s string) string {
|
||||
// SSE spec: multi-line data uses multiple "data:" lines
|
||||
// But we use JSON encoding to avoid this complexity
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// HandleKlines proxies kline data from Binance.
|
||||
func (w *WebHandler) HandleKlines(rw http.ResponseWriter, r *http.Request) {
|
||||
symbol := r.URL.Query().Get("symbol")
|
||||
if symbol == "" {
|
||||
symbol = "BTCUSDT"
|
||||
}
|
||||
interval := r.URL.Query().Get("interval")
|
||||
if interval == "" {
|
||||
interval = "1h"
|
||||
}
|
||||
|
||||
if !validSymbolRe.MatchString(symbol) {
|
||||
writeJSON(rw, 400, map[string]string{"error": "invalid symbol"})
|
||||
return
|
||||
}
|
||||
if !validIntervalRe.MatchString(interval) {
|
||||
writeJSON(rw, 400, map[string]string{"error": "invalid interval"})
|
||||
return
|
||||
}
|
||||
|
||||
proxyBinance(rw, r.Context(), fmt.Sprintf("https://fapi.binance.com/fapi/v1/klines?symbol=%s&interval=%s&limit=300", symbol, interval))
|
||||
}
|
||||
|
||||
// HandleTicker proxies ticker data from Binance.
|
||||
func (w *WebHandler) HandleTicker(rw http.ResponseWriter, r *http.Request) {
|
||||
symbol := r.URL.Query().Get("symbol")
|
||||
if symbol == "" {
|
||||
symbol = "BTCUSDT"
|
||||
}
|
||||
|
||||
if !validSymbolRe.MatchString(symbol) {
|
||||
writeJSON(rw, 400, map[string]string{"error": "invalid symbol"})
|
||||
return
|
||||
}
|
||||
|
||||
proxyBinance(rw, r.Context(), fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", symbol))
|
||||
}
|
||||
|
||||
// HandleTickers handles GET /api/agent/tickers?symbols=BTCUSDT,ETHUSDT,SOLUSDT
|
||||
// Batch endpoint: fetches multiple tickers concurrently, returns array.
|
||||
func (w *WebHandler) HandleTickers(rw http.ResponseWriter, r *http.Request) {
|
||||
symbolsParam := r.URL.Query().Get("symbols")
|
||||
if symbolsParam == "" {
|
||||
symbolsParam = "BTCUSDT,ETHUSDT,SOLUSDT"
|
||||
}
|
||||
|
||||
// Validate symbols
|
||||
var symbols []string
|
||||
for _, s := range splitComma(symbolsParam) {
|
||||
if validSymbolRe.MatchString(s) {
|
||||
symbols = append(symbols, s)
|
||||
}
|
||||
}
|
||||
if len(symbols) == 0 {
|
||||
writeJSON(rw, 400, map[string]string{"error": "no valid symbols"})
|
||||
return
|
||||
}
|
||||
if len(symbols) > 20 {
|
||||
writeJSON(rw, 400, map[string]string{"error": "max 20 symbols"})
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch all tickers concurrently with context propagation
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
type result struct {
|
||||
idx int
|
||||
data json.RawMessage
|
||||
}
|
||||
results := make(chan result, len(symbols))
|
||||
for i, sym := range symbols {
|
||||
idx, s := i, sym
|
||||
safe.GoNamed("ticker-fetch-"+s, func() {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET",
|
||||
fmt.Sprintf("https://fapi.binance.com/fapi/v1/ticker/24hr?symbol=%s", s), nil)
|
||||
if err != nil {
|
||||
results <- result{idx: idx}
|
||||
return
|
||||
}
|
||||
resp, err := binanceClient.Do(req)
|
||||
if err != nil {
|
||||
results <- result{idx: idx}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
results <- result{idx: idx}
|
||||
return
|
||||
}
|
||||
body, err := safe.ReadAllLimited(resp.Body, 16*1024)
|
||||
if err != nil {
|
||||
results <- result{idx: idx}
|
||||
return
|
||||
}
|
||||
results <- result{idx: idx, data: body}
|
||||
})
|
||||
}
|
||||
|
||||
// Collect results in order
|
||||
ordered := make([]json.RawMessage, len(symbols))
|
||||
for range symbols {
|
||||
r := <-results
|
||||
if r.data != nil {
|
||||
ordered[r.idx] = r.data
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out nil entries and write response
|
||||
out := make([]json.RawMessage, 0, len(ordered))
|
||||
for _, d := range ordered {
|
||||
if d != nil {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(rw).Encode(out)
|
||||
}
|
||||
|
||||
// commaRe is pre-compiled for splitComma — avoids recompiling on every call.
|
||||
var commaRe = regexp.MustCompile(`\s*,\s*`)
|
||||
|
||||
// splitComma splits a comma-separated string, trims whitespace, skips empty.
|
||||
func splitComma(s string) []string {
|
||||
var parts []string
|
||||
for _, p := range commaRe.Split(s, -1) {
|
||||
if p != "" {
|
||||
parts = append(parts, p)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func proxyBinance(rw http.ResponseWriter, ctx context.Context, url string) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
writeJSON(rw, 500, map[string]string{"error": "failed to create request"})
|
||||
return
|
||||
}
|
||||
resp, err := binanceClient.Do(req)
|
||||
if err != nil {
|
||||
// Distinguish client cancellation from upstream failures
|
||||
if ctx.Err() != nil {
|
||||
return // Client disconnected, no point writing response
|
||||
}
|
||||
writeJSON(rw, 502, map[string]string{"error": "upstream request failed"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Forward upstream error status codes instead of silently proxying bad data
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
writeJSON(rw, 502, map[string]string{"error": fmt.Sprintf("upstream returned status %d", resp.StatusCode)})
|
||||
return
|
||||
}
|
||||
|
||||
rw.Header().Set("Content-Type", "application/json")
|
||||
// CORS is handled by the gin middleware — no need to set it here
|
||||
// Limit response body to 2MB to prevent memory exhaustion
|
||||
io.Copy(rw, io.LimitReader(resp.Body, 2*1024*1024))
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// CORS is handled by the gin middleware — no need to set it here
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -1,521 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
const (
|
||||
workflowTaskPending = "pending"
|
||||
workflowTaskRunning = "running"
|
||||
workflowTaskCompleted = "completed"
|
||||
workflowTaskFailed = "failed"
|
||||
)
|
||||
|
||||
type WorkflowTask struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Skill string `json:"skill,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Request string `json:"request,omitempty"`
|
||||
DependsOn []string `json:"depends_on,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type WorkflowSession struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
OriginalRequest string `json:"original_request,omitempty"`
|
||||
Tasks []WorkflowTask `json:"tasks,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type workflowDecomposition struct {
|
||||
Tasks []WorkflowTask `json:"tasks"`
|
||||
}
|
||||
|
||||
func workflowSessionConfigKey(userID int64) string {
|
||||
return fmt.Sprintf("agent_workflow_session_%d", userID)
|
||||
}
|
||||
|
||||
func normalizeWorkflowSession(session WorkflowSession) WorkflowSession {
|
||||
session.OriginalRequest = strings.TrimSpace(session.OriginalRequest)
|
||||
normalized := make([]WorkflowTask, 0, len(session.Tasks))
|
||||
for i, task := range session.Tasks {
|
||||
task.ID = strings.TrimSpace(task.ID)
|
||||
if task.ID == "" {
|
||||
task.ID = fmt.Sprintf("task_%d", i+1)
|
||||
}
|
||||
task.Skill = strings.TrimSpace(task.Skill)
|
||||
task.Action = normalizeAtomicSkillAction(task.Skill, task.Action)
|
||||
task.Request = strings.TrimSpace(task.Request)
|
||||
task.DependsOn = cleanStringList(task.DependsOn)
|
||||
task.Status = strings.TrimSpace(task.Status)
|
||||
if task.Status == "" {
|
||||
task.Status = workflowTaskPending
|
||||
}
|
||||
task.Error = strings.TrimSpace(task.Error)
|
||||
if task.Skill == "" || task.Action == "" || task.Request == "" {
|
||||
continue
|
||||
}
|
||||
normalized = append(normalized, task)
|
||||
}
|
||||
session.Tasks = normalized
|
||||
if len(session.Tasks) == 0 {
|
||||
return WorkflowSession{}
|
||||
}
|
||||
if session.UpdatedAt == "" {
|
||||
session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
func (a *Agent) getWorkflowSession(userID int64) WorkflowSession {
|
||||
if a.store == nil {
|
||||
return WorkflowSession{}
|
||||
}
|
||||
raw, err := a.store.GetSystemConfig(workflowSessionConfigKey(userID))
|
||||
if err != nil || strings.TrimSpace(raw) == "" {
|
||||
return WorkflowSession{}
|
||||
}
|
||||
var session WorkflowSession
|
||||
if err := json.Unmarshal([]byte(raw), &session); err != nil {
|
||||
return WorkflowSession{}
|
||||
}
|
||||
return normalizeWorkflowSession(session)
|
||||
}
|
||||
|
||||
func (a *Agent) saveWorkflowSession(userID int64, session WorkflowSession) {
|
||||
if a.store == nil {
|
||||
return
|
||||
}
|
||||
session = normalizeWorkflowSession(session)
|
||||
if len(session.Tasks) == 0 {
|
||||
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), "")
|
||||
return
|
||||
}
|
||||
session.UserID = userID
|
||||
session.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
data, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), string(data))
|
||||
}
|
||||
|
||||
func (a *Agent) clearWorkflowSession(userID int64) {
|
||||
if a.store == nil {
|
||||
return
|
||||
}
|
||||
_ = a.store.SetSystemConfig(workflowSessionConfigKey(userID), "")
|
||||
}
|
||||
|
||||
func hasActiveWorkflowSession(session WorkflowSession) bool {
|
||||
if len(session.Tasks) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, task := range session.Tasks {
|
||||
if task.Status == workflowTaskPending || task.Status == workflowTaskRunning {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nextRunnableWorkflowTask(session WorkflowSession) (WorkflowTask, int, bool) {
|
||||
for i, task := range session.Tasks {
|
||||
if task.Status != workflowTaskPending && task.Status != workflowTaskRunning {
|
||||
continue
|
||||
}
|
||||
depsReady := true
|
||||
for _, dep := range task.DependsOn {
|
||||
ok := false
|
||||
for _, candidate := range session.Tasks {
|
||||
if candidate.ID == dep && candidate.Status == workflowTaskCompleted {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
depsReady = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if depsReady {
|
||||
return task, i, true
|
||||
}
|
||||
}
|
||||
return WorkflowTask{}, -1, false
|
||||
}
|
||||
|
||||
func supportedWorkflowSkill(skill, action string) bool {
|
||||
skill = strings.TrimSpace(skill)
|
||||
action = normalizeAtomicSkillAction(skill, action)
|
||||
if skill == "" || action == "" {
|
||||
return false
|
||||
}
|
||||
if _, ok := getSkillDAG(skill, action); ok {
|
||||
return true
|
||||
}
|
||||
switch skill {
|
||||
case "trader_management", "strategy_management", "model_management", "exchange_management":
|
||||
switch action {
|
||||
case "create", "query_list", "query_detail", "query_running", "activate":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Agent) tryWorkflowIntent(ctx context.Context, storeUserID string, userID int64, lang, text string, onEvent func(event, data string)) (string, bool, error) {
|
||||
if session := a.getWorkflowSession(userID); hasActiveWorkflowSession(session) {
|
||||
return a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, session, onEvent)
|
||||
}
|
||||
|
||||
decomposition, err := a.decomposeWorkflowIntent(ctx, userID, lang, text)
|
||||
if err != nil || len(decomposition.Tasks) <= 1 {
|
||||
return "", false, err
|
||||
}
|
||||
session := WorkflowSession{
|
||||
UserID: userID,
|
||||
OriginalRequest: text,
|
||||
Tasks: decomposition.Tasks,
|
||||
}
|
||||
a.saveWorkflowSession(userID, session)
|
||||
return a.handleWorkflowSession(ctx, storeUserID, userID, lang, text, session, onEvent)
|
||||
}
|
||||
|
||||
func (a *Agent) handleWorkflowSession(ctx context.Context, storeUserID string, userID int64, lang, text string, session WorkflowSession, onEvent func(event, data string)) (string, bool, error) {
|
||||
if isExplicitFlowAbort(text) {
|
||||
a.clearSkillSession(userID)
|
||||
a.clearWorkflowSession(userID)
|
||||
if lang == "zh" {
|
||||
return "已取消当前任务流。", true, nil
|
||||
}
|
||||
return "Cancelled the current workflow.", true, nil
|
||||
}
|
||||
|
||||
if activeSkill := a.getSkillSession(userID); strings.TrimSpace(activeSkill.Name) != "" {
|
||||
answer, handled := a.tryHardSkill(ctx, storeUserID, userID, lang, text, onEvent)
|
||||
if !handled {
|
||||
return "", false, nil
|
||||
}
|
||||
session = a.getWorkflowSession(userID)
|
||||
if hasActiveWorkflowSession(session) && strings.TrimSpace(a.getSkillSession(userID).Name) == "" {
|
||||
session = markCurrentWorkflowTask(session, workflowTaskCompleted, "")
|
||||
a.saveWorkflowSession(userID, session)
|
||||
if final, done, err := a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent); done || err != nil {
|
||||
if final != "" && answer != "" {
|
||||
return answer + "\n\n" + final, true, err
|
||||
}
|
||||
if answer != "" {
|
||||
return answer, true, err
|
||||
}
|
||||
return final, true, err
|
||||
}
|
||||
}
|
||||
return answer, true, nil
|
||||
}
|
||||
|
||||
return a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent)
|
||||
}
|
||||
|
||||
func (a *Agent) maybeAdvanceWorkflow(ctx context.Context, storeUserID string, userID int64, lang string, session WorkflowSession, onEvent func(event, data string)) (string, bool, error) {
|
||||
task, index, ok := nextRunnableWorkflowTask(session)
|
||||
if !ok {
|
||||
summary := a.generateWorkflowSummary(ctx, userID, lang, session)
|
||||
a.clearWorkflowSession(userID)
|
||||
if summary == "" {
|
||||
if lang == "zh" {
|
||||
summary = "已完成当前任务流。"
|
||||
} else {
|
||||
summary = "Completed the current workflow."
|
||||
}
|
||||
}
|
||||
if onEvent != nil {
|
||||
onEvent(StreamEventPlan, summary)
|
||||
onEvent(StreamEventDelta, summary)
|
||||
}
|
||||
return summary, true, nil
|
||||
}
|
||||
|
||||
session.Tasks[index].Status = workflowTaskRunning
|
||||
a.saveWorkflowSession(userID, session)
|
||||
taskSession := skillSession{Name: task.Skill, Action: task.Action, Phase: "collecting"}
|
||||
a.saveSkillSession(userID, taskSession)
|
||||
|
||||
if onEvent != nil {
|
||||
onEvent(StreamEventPlan, a.formatWorkflowStatus(lang, session))
|
||||
onEvent(StreamEventTool, "workflow:"+task.Skill+":"+task.Action)
|
||||
}
|
||||
|
||||
answer, handled := a.tryHardSkill(ctx, storeUserID, userID, lang, task.Request, onEvent)
|
||||
if !handled {
|
||||
session.Tasks[index].Status = workflowTaskFailed
|
||||
session.Tasks[index].Error = "task_not_handled"
|
||||
a.saveWorkflowSession(userID, session)
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(a.getSkillSession(userID).Name) == "" {
|
||||
session = a.getWorkflowSession(userID)
|
||||
session = markCurrentWorkflowTask(session, workflowTaskCompleted, "")
|
||||
a.saveWorkflowSession(userID, session)
|
||||
if more, ok, err := a.maybeAdvanceWorkflow(ctx, storeUserID, userID, lang, session, onEvent); ok || err != nil {
|
||||
if answer != "" && more != "" {
|
||||
return answer + "\n\n" + more, true, err
|
||||
}
|
||||
if answer != "" {
|
||||
return answer, true, err
|
||||
}
|
||||
return more, true, err
|
||||
}
|
||||
}
|
||||
return answer, true, nil
|
||||
}
|
||||
|
||||
func markCurrentWorkflowTask(session WorkflowSession, status, errMsg string) WorkflowSession {
|
||||
for i := range session.Tasks {
|
||||
if session.Tasks[i].Status == workflowTaskRunning {
|
||||
session.Tasks[i].Status = status
|
||||
session.Tasks[i].Error = strings.TrimSpace(errMsg)
|
||||
return session
|
||||
}
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
func (a *Agent) formatWorkflowStatus(lang string, session WorkflowSession) string {
|
||||
parts := make([]string, 0, len(session.Tasks))
|
||||
for _, task := range session.Tasks {
|
||||
label := task.Request
|
||||
if label == "" {
|
||||
label = task.Skill + ":" + task.Action
|
||||
}
|
||||
switch task.Status {
|
||||
case workflowTaskCompleted:
|
||||
label = "✓ " + label
|
||||
case workflowTaskRunning:
|
||||
label = "→ " + label
|
||||
default:
|
||||
label = "· " + label
|
||||
}
|
||||
parts = append(parts, label)
|
||||
}
|
||||
if lang == "zh" {
|
||||
return "任务流:" + strings.Join(parts, " | ")
|
||||
}
|
||||
return "Workflow: " + strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
func (a *Agent) generateWorkflowSummary(ctx context.Context, userID int64, lang string, session WorkflowSession) string {
|
||||
completed := make([]string, 0, len(session.Tasks))
|
||||
for _, task := range session.Tasks {
|
||||
if task.Status == workflowTaskCompleted {
|
||||
completed = append(completed, task.Request)
|
||||
}
|
||||
}
|
||||
if len(completed) == 0 {
|
||||
return ""
|
||||
}
|
||||
if a.aiClient == nil {
|
||||
if lang == "zh" {
|
||||
return "已完成这些任务:" + strings.Join(completed, ";")
|
||||
}
|
||||
return "Completed these tasks: " + strings.Join(completed, "; ")
|
||||
}
|
||||
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
|
||||
defer cancel()
|
||||
systemPrompt := `You are summarizing a finished workflow for NOFXi.
|
||||
Return one short user-facing summary in the user's language.
|
||||
Do not mention internal DAG, scheduler, or JSON.`
|
||||
userPrompt := fmt.Sprintf("Language: %s\nOriginal request: %s\nCompleted tasks:\n- %s", lang, session.OriginalRequest, strings.Join(completed, "\n- "))
|
||||
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
|
||||
Messages: []mcp.Message{
|
||||
mcp.NewSystemMessage(systemPrompt),
|
||||
mcp.NewUserMessage(userPrompt),
|
||||
},
|
||||
Ctx: stageCtx,
|
||||
})
|
||||
if err != nil {
|
||||
if lang == "zh" {
|
||||
return "已完成这些任务:" + strings.Join(completed, ";")
|
||||
}
|
||||
return "Completed these tasks: " + strings.Join(completed, "; ")
|
||||
}
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
|
||||
func (a *Agent) decomposeWorkflowIntent(ctx context.Context, userID int64, lang, text string) (workflowDecomposition, error) {
|
||||
if !looksLikeMultiTaskIntent(text) {
|
||||
return workflowDecomposition{}, nil
|
||||
}
|
||||
if a.aiClient != nil {
|
||||
if dec, err := a.decomposeWorkflowIntentWithLLM(ctx, userID, lang, text); err == nil && len(dec.Tasks) > 1 {
|
||||
return dec, nil
|
||||
}
|
||||
}
|
||||
return a.decomposeWorkflowIntentFallback(text), nil
|
||||
}
|
||||
|
||||
func looksLikeMultiTaskIntent(text string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if lower == "" {
|
||||
return false
|
||||
}
|
||||
connectors := []string{",", ",", "然后", "再", "并且", "并", "同时", "and", "then"}
|
||||
count := 0
|
||||
for _, c := range connectors {
|
||||
if strings.Contains(lower, c) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (a *Agent) decomposeWorkflowIntentWithLLM(ctx context.Context, userID int64, lang, text string) (workflowDecomposition, error) {
|
||||
stageCtx, cancel := withPlannerStageTimeout(ctx, directReplyTimeout)
|
||||
defer cancel()
|
||||
systemPrompt := `You decompose one NOFXi user request into a small task graph.
|
||||
Return JSON only. No markdown.
|
||||
Only use these skills: trader_management, strategy_management, model_management, exchange_management.
|
||||
Only use one atomic action per task.
|
||||
Each task must include:
|
||||
- id
|
||||
- skill
|
||||
- action
|
||||
- request
|
||||
- depends_on (array, may be empty)
|
||||
If the request is effectively a single task, return one task only.`
|
||||
userPrompt := fmt.Sprintf("Language: %s\nUser request: %s", lang, text)
|
||||
raw, err := a.aiClient.CallWithRequest(&mcp.Request{
|
||||
Messages: []mcp.Message{
|
||||
mcp.NewSystemMessage(systemPrompt),
|
||||
mcp.NewUserMessage(userPrompt),
|
||||
},
|
||||
Ctx: stageCtx,
|
||||
})
|
||||
if err != nil {
|
||||
return workflowDecomposition{}, err
|
||||
}
|
||||
return parseWorkflowDecomposition(raw)
|
||||
}
|
||||
|
||||
func parseWorkflowDecomposition(raw string) (workflowDecomposition, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimPrefix(raw, "```json")
|
||||
raw = strings.TrimPrefix(raw, "```")
|
||||
raw = strings.TrimSuffix(raw, "```")
|
||||
raw = strings.TrimSpace(raw)
|
||||
var out workflowDecomposition
|
||||
if err := json.Unmarshal([]byte(raw), &out); err == nil {
|
||||
out = normalizeWorkflowDecomposition(out)
|
||||
return out, nil
|
||||
}
|
||||
start := strings.Index(raw, "{")
|
||||
end := strings.LastIndex(raw, "}")
|
||||
if start >= 0 && end > start {
|
||||
if err := json.Unmarshal([]byte(raw[start:end+1]), &out); err == nil {
|
||||
out = normalizeWorkflowDecomposition(out)
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
return workflowDecomposition{}, fmt.Errorf("invalid workflow json")
|
||||
}
|
||||
|
||||
func normalizeWorkflowDecomposition(out workflowDecomposition) workflowDecomposition {
|
||||
normalized := make([]WorkflowTask, 0, len(out.Tasks))
|
||||
for i, task := range out.Tasks {
|
||||
task.ID = strings.TrimSpace(task.ID)
|
||||
if task.ID == "" {
|
||||
task.ID = fmt.Sprintf("task_%d", i+1)
|
||||
}
|
||||
task.Skill = strings.TrimSpace(task.Skill)
|
||||
task.Action = normalizeAtomicSkillAction(task.Skill, task.Action)
|
||||
task.Request = strings.TrimSpace(task.Request)
|
||||
task.DependsOn = cleanStringList(task.DependsOn)
|
||||
if !supportedWorkflowSkill(task.Skill, task.Action) || task.Request == "" {
|
||||
continue
|
||||
}
|
||||
task.Status = workflowTaskPending
|
||||
normalized = append(normalized, task)
|
||||
}
|
||||
out.Tasks = normalized
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *Agent) decomposeWorkflowIntentFallback(text string) workflowDecomposition {
|
||||
segments := splitWorkflowSegments(text)
|
||||
tasks := make([]WorkflowTask, 0, len(segments))
|
||||
for i, segment := range segments {
|
||||
task, ok := classifyWorkflowTask(segment)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
task.ID = fmt.Sprintf("task_%d", i+1)
|
||||
task.Status = workflowTaskPending
|
||||
if len(tasks) > 0 {
|
||||
task.DependsOn = []string{tasks[len(tasks)-1].ID}
|
||||
}
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
return workflowDecomposition{Tasks: tasks}
|
||||
}
|
||||
|
||||
func splitWorkflowSegments(text string) []string {
|
||||
parts := []string{strings.TrimSpace(text)}
|
||||
separators := []string{",", ",", "然后", "再", "并且", "同时", " and then ", " then ", " and "}
|
||||
for _, sep := range separators {
|
||||
next := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
split := strings.Split(part, sep)
|
||||
for _, candidate := range split {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate != "" {
|
||||
next = append(next, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
parts = next
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func classifyWorkflowTask(text string) (WorkflowTask, bool) {
|
||||
segment := strings.TrimSpace(text)
|
||||
if segment == "" {
|
||||
return WorkflowTask{}, false
|
||||
}
|
||||
switch {
|
||||
case detectCreateTraderSkill(segment):
|
||||
return WorkflowTask{Skill: "trader_management", Action: "create", Request: segment}, true
|
||||
case detectTraderManagementIntent(segment):
|
||||
action := normalizeAtomicSkillAction("trader_management", detectManagementAction(segment, "trader"))
|
||||
if supportedWorkflowSkill("trader_management", action) {
|
||||
return WorkflowTask{Skill: "trader_management", Action: action, Request: segment}, true
|
||||
}
|
||||
case detectExchangeManagementIntent(segment):
|
||||
action := normalizeAtomicSkillAction("exchange_management", detectManagementAction(segment, "exchange"))
|
||||
if supportedWorkflowSkill("exchange_management", action) {
|
||||
return WorkflowTask{Skill: "exchange_management", Action: action, Request: segment}, true
|
||||
}
|
||||
case detectModelManagementIntent(segment):
|
||||
action := normalizeAtomicSkillAction("model_management", detectManagementAction(segment, "model"))
|
||||
if supportedWorkflowSkill("model_management", action) {
|
||||
return WorkflowTask{Skill: "model_management", Action: action, Request: segment}, true
|
||||
}
|
||||
case detectStrategyManagementIntent(segment):
|
||||
action := normalizeAtomicSkillAction("strategy_management", detectManagementAction(segment, "strategy"))
|
||||
if action == "" && wantsStrategyDetails(segment) {
|
||||
action = "query_detail"
|
||||
}
|
||||
if supportedWorkflowSkill("strategy_management", action) {
|
||||
return WorkflowTask{Skill: "strategy_management", Action: action, Request: segment}, true
|
||||
}
|
||||
}
|
||||
return WorkflowTask{}, false
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSplitWorkflowSegments(t *testing.T) {
|
||||
got := splitWorkflowSegments("把策略删了,再把交易所改名")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 segments, got %d: %#v", len(got), got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyWorkflowTask(t *testing.T) {
|
||||
task, ok := classifyWorkflowTask("把策略删了")
|
||||
if !ok {
|
||||
t.Fatal("expected task")
|
||||
}
|
||||
if task.Skill != "strategy_management" || task.Action != "delete" {
|
||||
t.Fatalf("unexpected task: %+v", task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackWorkflowDecompositionBuildsTwoTasks(t *testing.T) {
|
||||
a := &Agent{}
|
||||
out := a.decomposeWorkflowIntentFallback("把策略删了,再把交易所改名")
|
||||
if len(out.Tasks) != 2 {
|
||||
t.Fatalf("expected 2 tasks, got %d", len(out.Tasks))
|
||||
}
|
||||
if out.Tasks[0].Skill != "strategy_management" {
|
||||
t.Fatalf("unexpected first task: %+v", out.Tasks[0])
|
||||
}
|
||||
if out.Tasks[1].Skill != "exchange_management" {
|
||||
t.Fatalf("unexpected second task: %+v", out.Tasks[1])
|
||||
}
|
||||
if len(out.Tasks[1].DependsOn) != 1 || out.Tasks[1].DependsOn[0] != out.Tasks[0].ID {
|
||||
t.Fatalf("expected dependency on first task, got %+v", out.Tasks[1].DependsOn)
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"nofx/agent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type agentPreferencePayload struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGetAgentPreferences(c *gin.Context) {
|
||||
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
|
||||
raw, err := s.store.GetSystemConfig(agent.PreferencesConfigKey(uid))
|
||||
if err != nil || strings.TrimSpace(raw) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"preferences": []agent.PersistentPreference{}})
|
||||
return
|
||||
}
|
||||
|
||||
var prefs []agent.PersistentPreference
|
||||
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"preferences": []agent.PersistentPreference{}})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateAgentPreference(c *gin.Context) {
|
||||
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
|
||||
|
||||
var req agentPreferencePayload
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Text) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "text required"})
|
||||
return
|
||||
}
|
||||
|
||||
created, err := agent.NewPersistentPreference(req.Text)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
prefs := s.loadAgentPreferences(uid)
|
||||
prefs = append([]agent.PersistentPreference{created}, prefs...)
|
||||
if len(prefs) > 20 {
|
||||
prefs = prefs[:20]
|
||||
}
|
||||
|
||||
if err := s.saveAgentPreferences(uid, prefs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save preference"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAgentPreference(c *gin.Context) {
|
||||
uid := agent.SessionUserIDFromKey(c.GetString("user_id"))
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id required"})
|
||||
return
|
||||
}
|
||||
|
||||
prefs := s.loadAgentPreferences(uid)
|
||||
filtered := prefs[:0]
|
||||
for _, pref := range prefs {
|
||||
if pref.ID != id {
|
||||
filtered = append(filtered, pref)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.saveAgentPreferences(uid, filtered); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preference"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"preferences": filtered})
|
||||
}
|
||||
|
||||
func (s *Server) loadAgentPreferences(userID int64) []agent.PersistentPreference {
|
||||
raw, err := s.store.GetSystemConfig(agent.PreferencesConfigKey(userID))
|
||||
if err != nil || strings.TrimSpace(raw) == "" {
|
||||
return []agent.PersistentPreference{}
|
||||
}
|
||||
|
||||
var prefs []agent.PersistentPreference
|
||||
if err := json.Unmarshal([]byte(raw), &prefs); err != nil {
|
||||
return []agent.PersistentPreference{}
|
||||
}
|
||||
return prefs
|
||||
}
|
||||
|
||||
func (s *Server) saveAgentPreferences(userID int64, prefs []agent.PersistentPreference) error {
|
||||
data, err := json.Marshal(prefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.SetSystemConfig(agent.PreferencesConfigKey(userID), string(data))
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"nofx/agent"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterAgentHandler registers NOFXi agent API routes on the main router.
|
||||
// Chat endpoint requires authentication; market data endpoints are public.
|
||||
func (s *Server) RegisterAgentHandler(h *agent.WebHandler) {
|
||||
// Chat requires auth — can trigger trades and access account data
|
||||
s.router.POST("/api/agent/chat", s.authMiddleware(), func(c *gin.Context) {
|
||||
req := c.Request.WithContext(agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id")))
|
||||
h.HandleChat(c.Writer, req)
|
||||
})
|
||||
s.router.POST("/api/agent/chat/stream", s.authMiddleware(), func(c *gin.Context) {
|
||||
req := c.Request.WithContext(agent.WithStoreUserID(c.Request.Context(), c.GetString("user_id")))
|
||||
h.HandleChatStream(c.Writer, req)
|
||||
})
|
||||
// Public endpoints — read-only market data
|
||||
s.router.GET("/api/agent/health", gin.WrapF(h.HandleHealth))
|
||||
s.router.GET("/api/agent/klines", gin.WrapF(h.HandleKlines))
|
||||
s.router.GET("/api/agent/ticker", gin.WrapF(h.HandleTicker))
|
||||
s.router.GET("/api/agent/tickers", gin.WrapF(h.HandleTickers))
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
@@ -53,28 +52,16 @@ func (h *CryptoHandler) HandleGetPublicKey(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Encrypted Data Decryption Endpoint ====================
|
||||
|
||||
// HandleDecryptSensitiveData Decrypt encrypted data sent from client
|
||||
func (h *CryptoHandler) HandleDecryptSensitiveData(c *gin.Context) {
|
||||
var payload crypto.EncryptedPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
decrypted, err := h.cryptoService.DecryptSensitiveData(&payload)
|
||||
if err != nil {
|
||||
log.Printf("❌ Decryption failed: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Decryption failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, map[string]string{
|
||||
"plaintext": decrypted,
|
||||
})
|
||||
}
|
||||
// ==================== Encrypted Data Decryption ====================
|
||||
//
|
||||
// SECURITY: there is deliberately NO public decrypt endpoint. Transport
|
||||
// encryption is one-directional — clients encrypt sensitive fields to the
|
||||
// server's RSA public key and the authenticated config-update handlers
|
||||
// (handleUpdateModelConfigs / handleUpdateExchangeConfigs / handleCreateExchange)
|
||||
// decrypt them server-side via cryptoService.DecryptSensitiveData. Exposing a
|
||||
// generic decrypt route would turn the server into a decryption oracle that any
|
||||
// unauthenticated caller could use to recover the plaintext of a captured
|
||||
// ciphertext, defeating the entire transport-encryption layer.
|
||||
|
||||
// ==================== Audit Log Query Endpoint ====================
|
||||
|
||||
|
||||
@@ -319,30 +319,24 @@ func accountAssetForExchange(exchangeType string) string {
|
||||
}
|
||||
|
||||
func missingExchangeCredentials(exchangeCfg *store.Exchange) (status string, code string, message string, missing bool) {
|
||||
switch exchangeCfg.ExchangeType {
|
||||
case "binance", "bybit", "gate", "indodax":
|
||||
if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key and secret key are required", true
|
||||
}
|
||||
case "okx", "bitget", "kucoin":
|
||||
if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" || exchangeCfg.Passphrase == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key, secret key, and passphrase are required", true
|
||||
}
|
||||
case "hyperliquid":
|
||||
if exchangeCfg.APIKey == "" || exchangeCfg.HyperliquidWalletAddr == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Private key and wallet address are required", true
|
||||
}
|
||||
case "aster":
|
||||
if exchangeCfg.AsterUser == "" || exchangeCfg.AsterSigner == "" || exchangeCfg.AsterPrivateKey == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Aster user, signer, and private key are required", true
|
||||
}
|
||||
case "lighter":
|
||||
if exchangeCfg.LighterWalletAddr == "" || exchangeCfg.LighterAPIKeyPrivateKey == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Wallet address and API key private key are required", true
|
||||
}
|
||||
default:
|
||||
missingFields := store.MissingRequiredExchangeCredentialFields(
|
||||
exchangeCfg.ExchangeType,
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
exchangeCfg.HyperliquidWalletAddr,
|
||||
exchangeCfg.AsterUser,
|
||||
exchangeCfg.AsterSigner,
|
||||
string(exchangeCfg.AsterPrivateKey),
|
||||
exchangeCfg.LighterWalletAddr,
|
||||
string(exchangeCfg.LighterAPIKeyPrivateKey),
|
||||
)
|
||||
if len(missingFields) > 0 {
|
||||
if len(missingFields) == 1 && missingFields[0] == "exchange_type" {
|
||||
return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type", true
|
||||
}
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Missing required fields: " + strings.Join(missingFields, ", "), true
|
||||
}
|
||||
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"nofx/crypto"
|
||||
"nofx/logger"
|
||||
"nofx/security"
|
||||
"nofx/store"
|
||||
"nofx/wallet"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -37,13 +38,19 @@ type SafeModelConfig struct {
|
||||
BalanceUSDC string `json:"balanceUsdc,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateModelConfigRequest struct {
|
||||
Models map[string]struct {
|
||||
// ModelConfigUpdate is a single model's update payload. It is a named type
|
||||
// (rather than an inline anonymous struct) so the log-sanitizer in utils.go is
|
||||
// guaranteed to stay in sync with this shape — a mismatch there is what let
|
||||
// plaintext credentials reach the logs previously.
|
||||
type ModelConfigUpdate struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
CustomAPIURL string `json:"custom_api_url"`
|
||||
CustomModelName string `json:"custom_model_name"`
|
||||
} `json:"models"`
|
||||
}
|
||||
|
||||
type UpdateModelConfigRequest struct {
|
||||
Models map[string]ModelConfigUpdate `json:"models"`
|
||||
}
|
||||
|
||||
// handleGetModelConfigs Get AI model configurations
|
||||
@@ -77,8 +84,11 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
||||
logger.Infof("✅ Found %d AI model configs", len(models))
|
||||
|
||||
// Convert to safe response structure, remove sensitive information
|
||||
safeModels := make([]SafeModelConfig, len(models))
|
||||
for i, model := range models {
|
||||
safeModels := make([]SafeModelConfig, 0, len(models))
|
||||
for _, model := range models {
|
||||
if !store.IsVisibleAIModel(model) {
|
||||
continue
|
||||
}
|
||||
safeModel := SafeModelConfig{
|
||||
ID: model.ID,
|
||||
Name: model.Name,
|
||||
@@ -100,7 +110,23 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
safeModels[i] = safeModel
|
||||
safeModels = append(safeModels, safeModel)
|
||||
}
|
||||
|
||||
if len(safeModels) == 0 {
|
||||
logger.Infof("⚠️ No visible AI models in database, returning defaults")
|
||||
defaultModels := []SafeModelConfig{
|
||||
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false, HasAPIKey: false},
|
||||
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false, HasAPIKey: false},
|
||||
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false, HasAPIKey: false},
|
||||
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false, HasAPIKey: false},
|
||||
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false, HasAPIKey: false},
|
||||
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false, HasAPIKey: false},
|
||||
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false, HasAPIKey: false},
|
||||
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false, HasAPIKey: false},
|
||||
}
|
||||
c.JSON(http.StatusOK, defaultModels)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, safeModels)
|
||||
@@ -205,7 +231,7 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
||||
// Don't return error here since model config was successfully updated to database
|
||||
}
|
||||
|
||||
logger.Infof("✓ AI model config updated: %+v", req.Models)
|
||||
logger.Infof("✓ AI model config updated: %+v", SanitizeModelConfigForLog(req.Models))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Model configuration updated"})
|
||||
}
|
||||
|
||||
@@ -217,10 +243,12 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
|
||||
{"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"},
|
||||
{"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"},
|
||||
{"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-6"},
|
||||
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3.1-pro"},
|
||||
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
|
||||
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
|
||||
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
|
||||
{"id": "minimax", "name": "MiniMax", "provider": "minimax", "defaultModel": "MiniMax-M2.7"},
|
||||
{"id": "blockrun-base", "name": "BlockRun (Base Wallet)", "provider": "blockrun-base", "defaultModel": "auto"},
|
||||
{"id": "blockrun-sol", "name": "BlockRun (Solana Wallet)", "provider": "blockrun-sol", "defaultModel": "auto"},
|
||||
{"id": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "deepseek-v4-flash"},
|
||||
}
|
||||
|
||||
|
||||
@@ -119,12 +119,25 @@ func (s *Server) handleCompetition(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, competition)
|
||||
}
|
||||
|
||||
// handleEquityHistory Return rate historical data
|
||||
// Query directly from database, not dependent on trader in memory (so historical data can be retrieved after restart)
|
||||
// handleEquityHistory returns equity history for a trader. This endpoint is
|
||||
// PUBLIC (used by the competition leaderboard), so it cannot use the
|
||||
// authenticated getTraderFromQuery helper. Instead, it validates that the
|
||||
// requested trader has explicitly opted into the public competition via
|
||||
// show_in_competition=true. Traders without that flag are not exposed.
|
||||
func (s *Server) handleEquityHistory(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
traderID := c.Query("trader_id")
|
||||
if traderID == "" {
|
||||
SafeBadRequest(c, "trader_id is required")
|
||||
return
|
||||
}
|
||||
trader, err := s.store.Trader().GetByID(traderID)
|
||||
if err != nil || trader == nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
if !trader.ShowInCompetition {
|
||||
// Do not leak that a private trader exists; report not found.
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -152,34 +165,80 @@ func (s *Server) handleEquityHistory(c *gin.Context) {
|
||||
MarginUsedPct float64 `json:"margin_used_pct"` // Margin used percentage
|
||||
}
|
||||
|
||||
// Use the balance of the first record as initial balance to calculate return rate
|
||||
initialBalance := snapshots[0].Balance
|
||||
initialBalance := trader.InitialBalance
|
||||
if initialBalance <= 0 {
|
||||
initialBalance = snapshots[0].TotalEquity
|
||||
}
|
||||
if initialBalance == 0 {
|
||||
initialBalance = 1 // Avoid division by zero
|
||||
}
|
||||
|
||||
var history []EquityPoint
|
||||
var lastSnapshotTime time.Time
|
||||
for _, snap := range snapshots {
|
||||
// Calculate PnL percentage
|
||||
totalPnL := snap.TotalEquity - initialBalance
|
||||
totalPnLPct := 0.0
|
||||
if initialBalance > 0 {
|
||||
totalPnLPct = (snap.UnrealizedPnL / initialBalance) * 100
|
||||
totalPnLPct = (totalPnL / initialBalance) * 100
|
||||
}
|
||||
|
||||
history = append(history, EquityPoint{
|
||||
Timestamp: snap.Timestamp.Format("2006-01-02 15:04:05"),
|
||||
TotalEquity: snap.TotalEquity,
|
||||
AvailableBalance: snap.Balance,
|
||||
TotalPnL: snap.UnrealizedPnL,
|
||||
AvailableBalance: equitySnapshotAvailableBalance(snap),
|
||||
TotalPnL: totalPnL,
|
||||
TotalPnLPct: totalPnLPct,
|
||||
PositionCount: snap.PositionCount,
|
||||
MarginUsedPct: snap.MarginUsedPct,
|
||||
})
|
||||
if snap.Timestamp.After(lastSnapshotTime) {
|
||||
lastSnapshotTime = snap.Timestamp
|
||||
}
|
||||
}
|
||||
|
||||
if runtimeTrader, err := s.traderManager.GetTrader(traderID); err == nil {
|
||||
if accountInfo, err := runtimeTrader.GetAccountInfo(); err == nil && time.Since(lastSnapshotTime) > 30*time.Second {
|
||||
totalEquity := floatFromMap(accountInfo, "total_equity")
|
||||
totalPnL := totalEquity - initialBalance
|
||||
totalPnLPct := 0.0
|
||||
if initialBalance > 0 {
|
||||
totalPnLPct = (totalPnL / initialBalance) * 100
|
||||
}
|
||||
history = append(history, EquityPoint{
|
||||
Timestamp: time.Now().UTC().Format("2006-01-02 15:04:05"),
|
||||
TotalEquity: totalEquity,
|
||||
AvailableBalance: floatFromMap(accountInfo, "available_balance"),
|
||||
TotalPnL: totalPnL,
|
||||
TotalPnLPct: totalPnLPct,
|
||||
PositionCount: int(floatFromMap(accountInfo, "position_count")),
|
||||
MarginUsedPct: floatFromMap(accountInfo, "margin_used_pct"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, history)
|
||||
}
|
||||
|
||||
func equitySnapshotAvailableBalance(snap *store.EquitySnapshot) float64 {
|
||||
if snap == nil {
|
||||
return 0
|
||||
}
|
||||
if snap.AvailableBalance != 0 || snap.PositionCount > 0 {
|
||||
return snap.AvailableBalance
|
||||
}
|
||||
return snap.Balance
|
||||
}
|
||||
|
||||
func floatFromMap(values map[string]interface{}, key string) float64 {
|
||||
if value, ok := values[key].(float64); ok {
|
||||
return value
|
||||
}
|
||||
if value, ok := values[key].(int); ok {
|
||||
return float64(value)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// handlePublicTraderList Get public trader list (no authentication required)
|
||||
func (s *Server) handlePublicTraderList(c *gin.Context) {
|
||||
// Get trader information from all users
|
||||
@@ -373,16 +432,18 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[s
|
||||
history := make([]map[string]interface{}, 0, len(snapshots)+1)
|
||||
var lastSnapshotTime time.Time
|
||||
for _, snap := range snapshots {
|
||||
totalPnL := snap.TotalEquity - initialBalance
|
||||
// Calculate PnL percentage: (current_equity - initial_balance) / initial_balance * 100
|
||||
pnlPct := 0.0
|
||||
if initialBalance > 0 {
|
||||
pnlPct = (snap.TotalEquity - initialBalance) / initialBalance * 100
|
||||
pnlPct = totalPnL / initialBalance * 100
|
||||
}
|
||||
|
||||
history = append(history, map[string]interface{}{
|
||||
"timestamp": snap.Timestamp,
|
||||
"total_equity": snap.TotalEquity,
|
||||
"total_pnl": snap.UnrealizedPnL,
|
||||
"available_balance": equitySnapshotAvailableBalance(snap),
|
||||
"total_pnl": totalPnL,
|
||||
"total_pnl_pct": pnlPct,
|
||||
"balance": snap.Balance,
|
||||
})
|
||||
@@ -397,18 +458,9 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[s
|
||||
if accountInfo, err := trader.GetAccountInfo(); err == nil {
|
||||
// Only append if it's been more than 30 seconds since last snapshot
|
||||
if now.Sub(lastSnapshotTime) > 30*time.Second {
|
||||
totalEquity := 0.0
|
||||
if v, ok := accountInfo["total_equity"].(float64); ok {
|
||||
totalEquity = v
|
||||
}
|
||||
totalPnL := 0.0
|
||||
if v, ok := accountInfo["total_pnl"].(float64); ok {
|
||||
totalPnL = v
|
||||
}
|
||||
walletBalance := 0.0
|
||||
if v, ok := accountInfo["wallet_balance"].(float64); ok {
|
||||
walletBalance = v
|
||||
}
|
||||
totalEquity := floatFromMap(accountInfo, "total_equity")
|
||||
totalPnL := totalEquity - initialBalance
|
||||
walletBalance := floatFromMap(accountInfo, "wallet_balance")
|
||||
pnlPct := 0.0
|
||||
if initialBalance > 0 {
|
||||
pnlPct = (totalEquity - initialBalance) / initialBalance * 100
|
||||
@@ -417,6 +469,7 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[s
|
||||
history = append(history, map[string]interface{}{
|
||||
"timestamp": now,
|
||||
"total_equity": totalEquity,
|
||||
"available_balance": floatFromMap(accountInfo, "available_balance"),
|
||||
"total_pnl": totalPnL,
|
||||
"total_pnl_pct": pnlPct,
|
||||
"balance": walletBalance,
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -30,22 +32,58 @@ type SafeExchangeConfig struct {
|
||||
Name string `json:"name"` // Display name
|
||||
Type string `json:"type"` // "cex" or "dex"
|
||||
Enabled bool `json:"enabled"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
HasSecretKey bool `json:"has_secret_key"`
|
||||
HasPassphrase bool `json:"has_passphrase"`
|
||||
Testnet bool `json:"testnet,omitempty"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquidUnifiedAccount"`
|
||||
HyperliquidBuilderApproved bool `json:"hyperliquidBuilderApproved"`
|
||||
HasAsterPrivateKey bool `json:"has_aster_private_key"`
|
||||
AsterUser string `json:"asterUser"` // Aster username (not sensitive)
|
||||
AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
|
||||
LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive)
|
||||
HasLighterPrivateKey bool `json:"has_lighter_private_key"`
|
||||
HasLighterAPIKey bool `json:"has_lighter_api_key_private_key"`
|
||||
}
|
||||
|
||||
type UpdateExchangeConfigRequest struct {
|
||||
Exchanges map[string]struct {
|
||||
func safeExchangeConfigFromStore(exchange *store.Exchange) SafeExchangeConfig {
|
||||
return SafeExchangeConfig{
|
||||
ID: exchange.ID,
|
||||
ExchangeType: exchange.ExchangeType,
|
||||
AccountName: exchange.AccountName,
|
||||
Name: exchange.Name,
|
||||
Type: exchange.Type,
|
||||
Enabled: exchange.Enabled,
|
||||
HasAPIKey: exchange.APIKey != "",
|
||||
HasSecretKey: exchange.SecretKey != "",
|
||||
HasPassphrase: exchange.Passphrase != "",
|
||||
Testnet: exchange.Testnet,
|
||||
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
|
||||
HyperliquidUnifiedAcct: exchange.HyperliquidUnifiedAcct,
|
||||
HyperliquidBuilderApproved: exchange.HyperliquidBuilderApproved,
|
||||
HasAsterPrivateKey: exchange.AsterPrivateKey != "",
|
||||
AsterUser: exchange.AsterUser,
|
||||
AsterSigner: exchange.AsterSigner,
|
||||
LighterWalletAddr: exchange.LighterWalletAddr,
|
||||
HasLighterPrivateKey: exchange.LighterPrivateKey != "",
|
||||
HasLighterAPIKey: exchange.LighterAPIKeyPrivateKey != "",
|
||||
}
|
||||
}
|
||||
|
||||
// ExchangeConfigUpdate is a single exchange account's update payload. It is a
|
||||
// named type (rather than an inline anonymous struct) so the log-sanitizer in
|
||||
// utils.go is guaranteed to cover every sensitive field — a drift between the
|
||||
// two shapes is what let passphrases / private keys reach the logs previously.
|
||||
type ExchangeConfigUpdate struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Passphrase string `json:"passphrase"` // OKX specific
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
|
||||
HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode
|
||||
HyperliquidBuilderApproved *bool `json:"hyperliquid_builder_approved"`
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
@@ -53,7 +91,10 @@ type UpdateExchangeConfigRequest struct {
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||
} `json:"exchanges"`
|
||||
}
|
||||
|
||||
type UpdateExchangeConfigRequest struct {
|
||||
Exchanges map[string]ExchangeConfigUpdate `json:"exchanges"`
|
||||
}
|
||||
|
||||
// CreateExchangeRequest request structure for creating a new exchange account
|
||||
@@ -66,7 +107,8 @@ type CreateExchangeRequest struct {
|
||||
Passphrase string `json:"passphrase"`
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
|
||||
HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
|
||||
HyperliquidBuilderApproved bool `json:"hyperliquid_builder_approved"`
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
@@ -96,26 +138,30 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
|
||||
logger.Infof("✅ Found %d exchange configs", len(exchanges))
|
||||
|
||||
// Convert to safe response structure, remove sensitive information
|
||||
safeExchanges := make([]SafeExchangeConfig, len(exchanges))
|
||||
for i, exchange := range exchanges {
|
||||
safeExchanges[i] = SafeExchangeConfig{
|
||||
ID: exchange.ID,
|
||||
ExchangeType: exchange.ExchangeType,
|
||||
AccountName: exchange.AccountName,
|
||||
Name: exchange.Name,
|
||||
Type: exchange.Type,
|
||||
Enabled: exchange.Enabled,
|
||||
Testnet: exchange.Testnet,
|
||||
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
|
||||
AsterUser: exchange.AsterUser,
|
||||
AsterSigner: exchange.AsterSigner,
|
||||
LighterWalletAddr: exchange.LighterWalletAddr,
|
||||
safeExchanges := make([]SafeExchangeConfig, 0, len(exchanges))
|
||||
for _, exchange := range exchanges {
|
||||
if !store.IsVisibleExchange(exchange) {
|
||||
continue
|
||||
}
|
||||
safeExchanges = append(safeExchanges, safeExchangeConfigFromStore(exchange))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, safeExchanges)
|
||||
}
|
||||
|
||||
func effectiveHyperliquidUnifiedAccount(exchangeType string, requested *bool, fallback ...bool) bool {
|
||||
if requested != nil {
|
||||
return *requested
|
||||
}
|
||||
if strings.EqualFold(exchangeType, "hyperliquid") {
|
||||
if len(fallback) > 0 {
|
||||
return fallback[0]
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleUpdateExchangeConfigs Update exchange configurations (supports both encrypted and plain text based on config)
|
||||
func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
@@ -179,13 +225,83 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
// Update each exchange's configuration and track traders that need reload
|
||||
tradersToReload := make(map[string]bool)
|
||||
for exchangeID, exchangeData := range req.Exchanges {
|
||||
existing, err := s.store.Exchange().GetByID(userID, exchangeID)
|
||||
if err != nil {
|
||||
SafeInternalError(c, fmt.Sprintf("Load exchange %s", exchangeID), err)
|
||||
return
|
||||
}
|
||||
effectiveAPIKey := strings.TrimSpace(exchangeData.APIKey)
|
||||
if effectiveAPIKey == "" {
|
||||
effectiveAPIKey = strings.TrimSpace(string(existing.APIKey))
|
||||
}
|
||||
effectiveSecretKey := strings.TrimSpace(exchangeData.SecretKey)
|
||||
if effectiveSecretKey == "" {
|
||||
effectiveSecretKey = strings.TrimSpace(string(existing.SecretKey))
|
||||
}
|
||||
effectivePassphrase := strings.TrimSpace(exchangeData.Passphrase)
|
||||
if effectivePassphrase == "" {
|
||||
effectivePassphrase = strings.TrimSpace(string(existing.Passphrase))
|
||||
}
|
||||
effectiveAsterPrivateKey := strings.TrimSpace(exchangeData.AsterPrivateKey)
|
||||
if effectiveAsterPrivateKey == "" {
|
||||
effectiveAsterPrivateKey = strings.TrimSpace(string(existing.AsterPrivateKey))
|
||||
}
|
||||
effectiveLighterAPIKeyPrivateKey := strings.TrimSpace(exchangeData.LighterAPIKeyPrivateKey)
|
||||
if effectiveLighterAPIKeyPrivateKey == "" {
|
||||
effectiveLighterAPIKeyPrivateKey = strings.TrimSpace(string(existing.LighterAPIKeyPrivateKey))
|
||||
}
|
||||
effectiveHyperliquidWalletAddr := strings.TrimSpace(exchangeData.HyperliquidWalletAddr)
|
||||
if effectiveHyperliquidWalletAddr == "" {
|
||||
effectiveHyperliquidWalletAddr = strings.TrimSpace(existing.HyperliquidWalletAddr)
|
||||
}
|
||||
effectiveAsterUser := strings.TrimSpace(exchangeData.AsterUser)
|
||||
if effectiveAsterUser == "" {
|
||||
effectiveAsterUser = strings.TrimSpace(existing.AsterUser)
|
||||
}
|
||||
effectiveAsterSigner := strings.TrimSpace(exchangeData.AsterSigner)
|
||||
if effectiveAsterSigner == "" {
|
||||
effectiveAsterSigner = strings.TrimSpace(existing.AsterSigner)
|
||||
}
|
||||
effectiveLighterWalletAddr := strings.TrimSpace(exchangeData.LighterWalletAddr)
|
||||
if effectiveLighterWalletAddr == "" {
|
||||
effectiveLighterWalletAddr = strings.TrimSpace(existing.LighterWalletAddr)
|
||||
}
|
||||
effectiveHyperliquidBuilderApproved := existing.HyperliquidBuilderApproved
|
||||
if exchangeData.HyperliquidBuilderApproved != nil {
|
||||
effectiveHyperliquidBuilderApproved = *exchangeData.HyperliquidBuilderApproved
|
||||
}
|
||||
effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount(
|
||||
existing.ExchangeType,
|
||||
exchangeData.HyperliquidUnifiedAcct,
|
||||
existing.HyperliquidUnifiedAcct,
|
||||
)
|
||||
|
||||
if missing := store.MissingRequiredExchangeCredentialFields(
|
||||
existing.ExchangeType,
|
||||
effectiveAPIKey,
|
||||
effectiveSecretKey,
|
||||
effectivePassphrase,
|
||||
effectiveHyperliquidWalletAddr,
|
||||
effectiveAsterUser,
|
||||
effectiveAsterSigner,
|
||||
effectiveAsterPrivateKey,
|
||||
effectiveLighterWalletAddr,
|
||||
effectiveLighterAPIKeyPrivateKey,
|
||||
); len(missing) > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Missing required exchange fields: %s", strings.Join(missing, ", ")),
|
||||
"missing_fields": missing,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Find traders using this exchange BEFORE updating
|
||||
traders, _ := s.store.Trader().ListByExchangeID(userID, exchangeID)
|
||||
for _, t := range traders {
|
||||
tradersToReload[t.ID] = true
|
||||
}
|
||||
|
||||
err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
|
||||
err = s.store.Exchange().Update(userID, exchangeID, true, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, effectiveHyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, effectiveHyperliquidBuilderApproved, effectiveAsterUser, effectiveAsterSigner, exchangeData.AsterPrivateKey, effectiveLighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
|
||||
if err != nil {
|
||||
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
|
||||
return
|
||||
@@ -207,7 +323,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
// Don't return error here since exchange config was successfully updated to database
|
||||
}
|
||||
|
||||
logger.Infof("✓ Exchange config updated: %+v", req.Exchanges)
|
||||
logger.Infof("✓ Exchange config updated: %+v", SanitizeExchangeConfigForLog(req.Exchanges))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Exchange configuration updated"})
|
||||
}
|
||||
|
||||
@@ -271,12 +387,31 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)})
|
||||
return
|
||||
}
|
||||
if missing := store.MissingRequiredExchangeCredentialFields(
|
||||
req.ExchangeType,
|
||||
req.APIKey,
|
||||
req.SecretKey,
|
||||
req.Passphrase,
|
||||
req.HyperliquidWalletAddr,
|
||||
req.AsterUser,
|
||||
req.AsterSigner,
|
||||
req.AsterPrivateKey,
|
||||
req.LighterWalletAddr,
|
||||
req.LighterAPIKeyPrivateKey,
|
||||
); len(missing) > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Missing required exchange fields: %s", strings.Join(missing, ", ")),
|
||||
"missing_fields": missing,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create new exchange account
|
||||
// Exchange configs only persist once complete; persisted configs are always enabled.
|
||||
effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount(req.ExchangeType, req.HyperliquidUnifiedAcct)
|
||||
id, err := s.store.Exchange().Create(
|
||||
userID, req.ExchangeType, req.AccountName, req.Enabled,
|
||||
userID, req.ExchangeType, req.AccountName, true,
|
||||
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
|
||||
req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct,
|
||||
req.HyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, req.HyperliquidBuilderApproved,
|
||||
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
|
||||
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
|
||||
)
|
||||
|
||||
66
api/handler_exchange_test.go
Normal file
66
api/handler_exchange_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"nofx/crypto"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
func TestSafeExchangeConfigFromStoreIncludesCredentialPresenceFlags(t *testing.T) {
|
||||
cfg := &store.Exchange{
|
||||
ID: "ex-1",
|
||||
ExchangeType: "okx",
|
||||
AccountName: "OKX Main",
|
||||
Name: "OKX Main",
|
||||
Type: "cex",
|
||||
Enabled: true,
|
||||
APIKey: crypto.EncryptedString("api-test-123"),
|
||||
SecretKey: crypto.EncryptedString("secret-test-123"),
|
||||
Passphrase: crypto.EncryptedString("passphrase-test-123"),
|
||||
HyperliquidUnifiedAcct: true,
|
||||
AsterPrivateKey: crypto.EncryptedString("aster-private-key"),
|
||||
LighterPrivateKey: crypto.EncryptedString("lighter-private-key"),
|
||||
LighterAPIKeyPrivateKey: crypto.EncryptedString("lighter-api-key-private-key"),
|
||||
}
|
||||
|
||||
safe := safeExchangeConfigFromStore(cfg)
|
||||
if !safe.HasAPIKey {
|
||||
t.Fatalf("expected has_api_key to be true")
|
||||
}
|
||||
if !safe.HasSecretKey {
|
||||
t.Fatalf("expected has_secret_key to be true")
|
||||
}
|
||||
if !safe.HasPassphrase {
|
||||
t.Fatalf("expected has_passphrase to be true")
|
||||
}
|
||||
if !safe.HasAsterPrivateKey {
|
||||
t.Fatalf("expected has_aster_private_key to be true")
|
||||
}
|
||||
if !safe.HasLighterPrivateKey {
|
||||
t.Fatalf("expected has_lighter_private_key to be true")
|
||||
}
|
||||
if !safe.HasLighterAPIKey {
|
||||
t.Fatalf("expected has_lighter_api_key_private_key to be true")
|
||||
}
|
||||
if !safe.HyperliquidUnifiedAcct {
|
||||
t.Fatalf("expected hyperliquid unified account to be exposed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveHyperliquidUnifiedAccountDefaultsAndPreserves(t *testing.T) {
|
||||
if !effectiveHyperliquidUnifiedAccount("hyperliquid", nil) {
|
||||
t.Fatalf("expected new hyperliquid accounts to default unified account on")
|
||||
}
|
||||
if effectiveHyperliquidUnifiedAccount("binance", nil) {
|
||||
t.Fatalf("expected non-hyperliquid accounts to default unified account off")
|
||||
}
|
||||
fallbackFalse := effectiveHyperliquidUnifiedAccount("hyperliquid", nil, false)
|
||||
if fallbackFalse {
|
||||
t.Fatalf("expected omitted update field to preserve existing false value")
|
||||
}
|
||||
requestedTrue := true
|
||||
if !effectiveHyperliquidUnifiedAccount("hyperliquid", &requestedTrue, false) {
|
||||
t.Fatalf("expected explicit true to override existing false value")
|
||||
}
|
||||
}
|
||||
413
api/handler_hyperliquid_wallet.go
Normal file
413
api/handler_hyperliquid_wallet.go
Normal file
@@ -0,0 +1,413 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHyperliquidBuilderAddress = "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d"
|
||||
// 0.05% (5 bps) — matches BuilderInfo.Fee=50 charged at order placement.
|
||||
// New wallet approvals sign this exact value; existing approvals at the
|
||||
// prior 0.1% cap remain valid because 0.05% is within their approved max.
|
||||
defaultHyperliquidBuilderMaxFee = "0.05%"
|
||||
hyperliquidExchangeURL = "https://api.hyperliquid.xyz/exchange"
|
||||
hyperliquidInfoURL = "https://api.hyperliquid.xyz/info"
|
||||
// nofxHyperliquidAgentName must match AGENT_NAME used by the frontend
|
||||
// approveAgent flow so we can locate the NOFX-managed agent on-chain.
|
||||
nofxHyperliquidAgentName = "NOFX Agent"
|
||||
)
|
||||
|
||||
type hyperliquidSubmitRequest struct {
|
||||
Action map[string]any `json:"action" binding:"required"`
|
||||
Nonce int64 `json:"nonce" binding:"required"`
|
||||
Signature struct {
|
||||
R string `json:"r" binding:"required"`
|
||||
S string `json:"s" binding:"required"`
|
||||
V int `json:"v"`
|
||||
} `json:"signature" binding:"required"`
|
||||
}
|
||||
|
||||
type hyperliquidConfigResponse struct {
|
||||
BuilderAddress string `json:"builderAddress"`
|
||||
BuilderMaxFee string `json:"builderMaxFee"`
|
||||
Chain string `json:"chain"`
|
||||
SignatureChain string `json:"signatureChainId"`
|
||||
}
|
||||
|
||||
type hyperliquidAccountSummary struct {
|
||||
Address string `json:"address"`
|
||||
AccountValue float64 `json:"accountValue"`
|
||||
Withdrawable float64 `json:"withdrawable"`
|
||||
TotalMarginUsed float64 `json:"totalMarginUsed"`
|
||||
UnrealizedPnl float64 `json:"unrealizedPnl"`
|
||||
OpenPositions int `json:"openPositions"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type hyperliquidAgentInfo struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
ValidUntil int64 `json:"validUntil"` // unix milliseconds
|
||||
}
|
||||
|
||||
type hyperliquidAgentResponse struct {
|
||||
// Agent is the NOFX-managed agent ("NOFX Agent"), nil when none is approved.
|
||||
Agent *hyperliquidAgentInfo `json:"agent"`
|
||||
// Agents lists every approved agent for the wallet (for visibility/cleanup).
|
||||
Agents []hyperliquidAgentInfo `json:"agents"`
|
||||
}
|
||||
|
||||
type hyperliquidClearinghouseState struct {
|
||||
MarginSummary struct {
|
||||
AccountValue string `json:"accountValue"`
|
||||
TotalMarginUsed string `json:"totalMarginUsed"`
|
||||
} `json:"marginSummary"`
|
||||
CrossMarginSummary struct {
|
||||
AccountValue string `json:"accountValue"`
|
||||
TotalMarginUsed string `json:"totalMarginUsed"`
|
||||
} `json:"crossMarginSummary"`
|
||||
Withdrawable string `json:"withdrawable"`
|
||||
AssetPositions []struct {
|
||||
Position struct {
|
||||
Szi string `json:"szi"`
|
||||
UnrealizedPnl string `json:"unrealizedPnl"`
|
||||
} `json:"position"`
|
||||
} `json:"assetPositions"`
|
||||
}
|
||||
|
||||
// agentValidUntilSuffix matches the " valid_until <ms>" suffix Hyperliquid uses
|
||||
// to encode an agent's expiry inside the agent name. Hyperliquid normally strips
|
||||
// it from the stored name, but we strip defensively before matching the slot.
|
||||
var agentValidUntilSuffix = regexp.MustCompile(` valid_until \d+$`)
|
||||
|
||||
func baseAgentName(name string) string {
|
||||
return strings.TrimSpace(agentValidUntilSuffix.ReplaceAllString(name, ""))
|
||||
}
|
||||
|
||||
func hyperliquidBuilderAddress() string {
|
||||
return defaultHyperliquidBuilderAddress
|
||||
}
|
||||
|
||||
func hyperliquidBuilderMaxFee() string {
|
||||
return defaultHyperliquidBuilderMaxFee
|
||||
}
|
||||
|
||||
func (s *Server) handleHyperliquidConnectConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, hyperliquidConfigResponse{
|
||||
BuilderAddress: hyperliquidBuilderAddress(),
|
||||
BuilderMaxFee: hyperliquidBuilderMaxFee(),
|
||||
Chain: "Mainnet",
|
||||
SignatureChain: "0x66eee",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHyperliquidAccount(c *gin.Context) {
|
||||
address := strings.ToLower(strings.TrimSpace(c.Query("address")))
|
||||
if !isEVMAddress(address) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"})
|
||||
return
|
||||
}
|
||||
|
||||
requestBody := map[string]any{
|
||||
"type": "clearinghouseState",
|
||||
"user": address,
|
||||
}
|
||||
body, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid balance request"})
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid balance request"})
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the balance request", "status": resp.StatusCode})
|
||||
return
|
||||
}
|
||||
|
||||
var state hyperliquidClearinghouseState
|
||||
if err := json.Unmarshal(respBody, &state); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid balance response"})
|
||||
return
|
||||
}
|
||||
|
||||
accountValue := parseFloatOrZero(state.MarginSummary.AccountValue)
|
||||
if accountValue == 0 {
|
||||
accountValue = parseFloatOrZero(state.CrossMarginSummary.AccountValue)
|
||||
}
|
||||
marginUsed := parseFloatOrZero(state.MarginSummary.TotalMarginUsed)
|
||||
if marginUsed == 0 {
|
||||
marginUsed = parseFloatOrZero(state.CrossMarginSummary.TotalMarginUsed)
|
||||
}
|
||||
|
||||
var unrealizedPnl float64
|
||||
openPositions := 0
|
||||
for _, position := range state.AssetPositions {
|
||||
size := parseFloatOrZero(position.Position.Szi)
|
||||
if size != 0 {
|
||||
openPositions++
|
||||
}
|
||||
unrealizedPnl += parseFloatOrZero(position.Position.UnrealizedPnl)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, hyperliquidAccountSummary{
|
||||
Address: address,
|
||||
AccountValue: accountValue,
|
||||
Withdrawable: parseFloatOrZero(state.Withdrawable),
|
||||
TotalMarginUsed: marginUsed,
|
||||
UnrealizedPnl: unrealizedPnl,
|
||||
OpenPositions: openPositions,
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleHyperliquidAgent reports the on-chain approved agents for a wallet,
|
||||
// including the NOFX agent's validUntil so the UI can show the expiry date and
|
||||
// warn before the 180-day authorization lapses.
|
||||
func (s *Server) handleHyperliquidAgent(c *gin.Context) {
|
||||
address := strings.ToLower(strings.TrimSpace(c.Query("address")))
|
||||
if !isEVMAddress(address) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := json.Marshal(map[string]any{"type": "extraAgents", "user": address})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid agent request"})
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid agent request"})
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the agent request", "status": resp.StatusCode})
|
||||
return
|
||||
}
|
||||
|
||||
// extraAgents returns null when no agents are approved.
|
||||
agents := []hyperliquidAgentInfo{}
|
||||
if len(respBody) > 0 && string(bytes.TrimSpace(respBody)) != "null" {
|
||||
if err := json.Unmarshal(respBody, &agents); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid agent response"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
out := hyperliquidAgentResponse{Agents: agents}
|
||||
for i := range agents {
|
||||
if strings.EqualFold(baseAgentName(agents[i].Name), nofxHyperliquidAgentName) {
|
||||
agent := agents[i]
|
||||
out.Agent = &agent
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleHyperliquidSubmitExchange(c *gin.Context) {
|
||||
var req hyperliquidSubmitRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid submit payload"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateSubmittedNonce(req.Action, req.Nonce); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
actionType, _ := req.Action["type"].(string)
|
||||
switch actionType {
|
||||
case "approveAgent":
|
||||
if err := validateApproveAgentAction(req.Action); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
case "approveBuilderFee":
|
||||
if err := validateApproveBuilderFeeAction(req.Action); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported Hyperliquid action"})
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"action": req.Action,
|
||||
"nonce": req.Nonce,
|
||||
"signature": req.Signature,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid payload"})
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
hlReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidExchangeURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid request"})
|
||||
return
|
||||
}
|
||||
hlReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(hlReq)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var decoded any
|
||||
if len(respBody) > 0 {
|
||||
_ = json.Unmarshal(respBody, &decoded)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the action", "status": resp.StatusCode, "response": decoded})
|
||||
return
|
||||
}
|
||||
|
||||
// Hyperliquid returns HTTP 200 even for logical failures, signalling them via
|
||||
// {"status":"err","response":"<message>"}. Without this check a rejected
|
||||
// approval (e.g. valid_until past the cap, or an unchanged agent) is reported
|
||||
// to the user as success while nothing changes on-chain.
|
||||
var hlResp struct {
|
||||
Status string `json:"status"`
|
||||
Response json.RawMessage `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &hlResp); err == nil && strings.EqualFold(hlResp.Status, "err") {
|
||||
msg := strings.TrimSpace(strings.Trim(string(hlResp.Response), `"`))
|
||||
if msg == "" {
|
||||
msg = "Hyperliquid rejected the action"
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": msg, "response": decoded})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "response": decoded})
|
||||
}
|
||||
|
||||
func validateApproveAgentAction(action map[string]any) error {
|
||||
if strings.TrimSpace(fmt.Sprint(action["agentAddress"])) == "" {
|
||||
return fmt.Errorf("missing agentAddress")
|
||||
}
|
||||
if strings.TrimSpace(fmt.Sprint(action["agentName"])) == "" {
|
||||
return fmt.Errorf("missing agentName")
|
||||
}
|
||||
return validateCommonHyperliquidSignedAction(action)
|
||||
}
|
||||
|
||||
func validateApproveBuilderFeeAction(action map[string]any) error {
|
||||
builder := strings.ToLower(strings.TrimSpace(fmt.Sprint(action["builder"])))
|
||||
if builder != hyperliquidBuilderAddress() {
|
||||
return fmt.Errorf("builder address mismatch")
|
||||
}
|
||||
if strings.TrimSpace(fmt.Sprint(action["maxFeeRate"])) != hyperliquidBuilderMaxFee() {
|
||||
return fmt.Errorf("builder max fee mismatch")
|
||||
}
|
||||
return validateCommonHyperliquidSignedAction(action)
|
||||
}
|
||||
|
||||
func validateCommonHyperliquidSignedAction(action map[string]any) error {
|
||||
if strings.TrimSpace(fmt.Sprint(action["signatureChainId"])) != "0x66eee" {
|
||||
return fmt.Errorf("invalid signatureChainId")
|
||||
}
|
||||
if strings.TrimSpace(fmt.Sprint(action["hyperliquidChain"])) != "Mainnet" {
|
||||
return fmt.Errorf("invalid hyperliquidChain")
|
||||
}
|
||||
if _, err := actionNonce(action); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSubmittedNonce(action map[string]any, submitted int64) error {
|
||||
actionValue, err := actionNonce(action)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if actionValue != submitted {
|
||||
return fmt.Errorf("nonce mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isEVMAddress(address string) bool {
|
||||
if len(address) != 42 || !strings.HasPrefix(address, "0x") {
|
||||
return false
|
||||
}
|
||||
for _, char := range address[2:] {
|
||||
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseFloatOrZero(value string) float64 {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func actionNonce(action map[string]any) (int64, error) {
|
||||
raw, ok := action["nonce"]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("missing nonce")
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case float64:
|
||||
return int64(value), nil
|
||||
case int64:
|
||||
return value, nil
|
||||
case json.Number:
|
||||
return value.Int64()
|
||||
case string:
|
||||
return strconv.ParseInt(value, 10, 64)
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid nonce")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -320,61 +321,207 @@ func (s *Server) getKlinesFromHyperliquid(symbol, interval string, limit int) ([
|
||||
return klines, nil
|
||||
}
|
||||
|
||||
func hyperliquidXYZDisplayBase(baseSymbol string) string {
|
||||
baseSymbol = strings.ToUpper(strings.TrimSpace(baseSymbol))
|
||||
// User-facing names should be product names, not exchange shorthand tickers.
|
||||
// Keep the internal symbol separate because Hyperliquid's xyz dex still routes
|
||||
// orders/candles by the short coin name (for example xyz:SMSN).
|
||||
fullNames := map[string]string{
|
||||
"XYZ100": "XYZ100",
|
||||
"TSLA": "TESLA",
|
||||
"NVDA": "NVIDIA",
|
||||
"GOLD": "GOLD",
|
||||
"HOOD": "ROBINHOOD",
|
||||
"INTC": "INTEL",
|
||||
"PLTR": "PALANTIR",
|
||||
"COIN": "COINBASE",
|
||||
"META": "META",
|
||||
"AAPL": "APPLE",
|
||||
"MSFT": "MICROSOFT",
|
||||
"ORCL": "ORACLE",
|
||||
"GOOGL": "GOOGLE",
|
||||
"AMZN": "AMAZON",
|
||||
"AMD": "AMD",
|
||||
"MU": "MICRON",
|
||||
"SNDK": "SANDISK",
|
||||
"MSTR": "MICROSTRATEGY",
|
||||
"CRCL": "CIRCLE",
|
||||
"NFLX": "NETFLIX",
|
||||
"COST": "COSTCO",
|
||||
"LLY": "ELI-LILLY",
|
||||
"SKHX": "SK-HYNIX",
|
||||
"TSM": "TSMC",
|
||||
"JPY": "JPY",
|
||||
"EUR": "EUR",
|
||||
"SILVER": "SILVER",
|
||||
"RIVN": "RIVIAN",
|
||||
"BABA": "ALIBABA",
|
||||
"CL": "CRUDE-OIL",
|
||||
"COPPER": "COPPER",
|
||||
"NATGAS": "NATURAL-GAS",
|
||||
"URANIUM": "URANIUM",
|
||||
"ALUMINIUM": "ALUMINIUM",
|
||||
"SMSN": "SAMSUNG",
|
||||
"PLATINUM": "PLATINUM",
|
||||
"USAR": "USA-RARE-EARTH",
|
||||
"CRWV": "COREWEAVE",
|
||||
"URNM": "URNM",
|
||||
"PALLADIUM": "PALLADIUM",
|
||||
"DXY": "DOLLAR-INDEX",
|
||||
"GME": "GAMESTOP",
|
||||
"KR200": "KOREA-200",
|
||||
"SOFTBANK": "SOFTBANK",
|
||||
"JP225": "JAPAN-225",
|
||||
"HYUNDAI": "HYUNDAI",
|
||||
"KIOXIA": "KIOXIA",
|
||||
"EWY": "SOUTH-KOREA-ETF",
|
||||
"EWJ": "JAPAN-ETF",
|
||||
"BRENTOIL": "BRENT-OIL",
|
||||
"VIX": "VIX",
|
||||
"HIMS": "HIMS-HERS",
|
||||
"SP500": "S&P-500",
|
||||
"DKNG": "DRAFTKINGS",
|
||||
"LITE": "LITECOIN",
|
||||
"CORN": "CORN",
|
||||
"XLE": "ENERGY-SECTOR-ETF",
|
||||
"WHEAT": "WHEAT",
|
||||
"TTF": "TTF-GAS",
|
||||
"BX": "BLACKSTONE",
|
||||
"PURRDAT": "PURRDAT",
|
||||
"MRVL": "MARVELL",
|
||||
"RKLB": "ROCKET-LAB",
|
||||
"BIRD": "BIRD",
|
||||
"VOL": "VOLATILITY",
|
||||
"DRAM": "DRAM",
|
||||
"CBRS": "COINBASE-PRE-IPO",
|
||||
"EWZ": "BRAZIL-ETF",
|
||||
"KRW": "KRW",
|
||||
"ZM": "ZOOM",
|
||||
"EBAY": "EBAY",
|
||||
"H100": "H100",
|
||||
"NIFTY": "NIFTY-50",
|
||||
"ARM": "ARM",
|
||||
"EWT": "TAIWAN-ETF",
|
||||
"GBP": "GBP",
|
||||
"SPCX": "SPACEX-PRE-IPO",
|
||||
"IBOV": "IBOVESPA",
|
||||
"ASML": "ASML",
|
||||
}
|
||||
if fullName, ok := fullNames[baseSymbol]; ok {
|
||||
return fullName
|
||||
}
|
||||
return baseSymbol
|
||||
}
|
||||
|
||||
func hyperliquidXYZCategory(baseSymbol string) string {
|
||||
baseSymbol = strings.ToUpper(strings.TrimSpace(baseSymbol))
|
||||
switch baseSymbol {
|
||||
case "GOLD", "SILVER", "CL", "COPPER", "NATGAS", "URANIUM", "ALUMINIUM", "PLATINUM", "PALLADIUM", "BRENTOIL", "CORN", "WHEAT", "TTF":
|
||||
return "commodity"
|
||||
case "XYZ100", "SP500", "JP225", "KR200", "DXY", "VIX", "XLE", "EWY", "EWJ", "EWZ", "EWT", "NIFTY", "IBOV":
|
||||
return "index"
|
||||
case "EUR", "JPY", "GBP", "KRW":
|
||||
return "forex"
|
||||
case "SPCX", "BIRD", "PURRDAT", "H100", "CBRS":
|
||||
return "pre_ipo"
|
||||
default:
|
||||
return "stock"
|
||||
}
|
||||
}
|
||||
|
||||
func hyperliquidCategoryOrder(category string) int {
|
||||
switch category {
|
||||
case "stock":
|
||||
return 0
|
||||
case "commodity":
|
||||
return 1
|
||||
case "index":
|
||||
return 2
|
||||
case "forex":
|
||||
return 3
|
||||
case "pre_ipo":
|
||||
return 4
|
||||
case "crypto":
|
||||
return 5
|
||||
default:
|
||||
return 99
|
||||
}
|
||||
}
|
||||
|
||||
// handleSymbols returns available symbols for a given exchange
|
||||
func (s *Server) handleSymbols(c *gin.Context) {
|
||||
exchange := c.DefaultQuery("exchange", "hyperliquid")
|
||||
|
||||
type SymbolInfo struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Display string `json:"display"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"` // crypto, stock, forex, commodity, index
|
||||
Exchange string `json:"exchange"`
|
||||
Volume24h float64 `json:"volume_24h"`
|
||||
MarkPrice float64 `json:"mark_price"`
|
||||
PrevDayPrice float64 `json:"prev_day_price,omitempty"`
|
||||
Change24hPct float64 `json:"change_24h_pct,omitempty"`
|
||||
MaxLeverage int `json:"maxLeverage,omitempty"`
|
||||
SzDecimals int `json:"sz_decimals,omitempty"`
|
||||
}
|
||||
|
||||
var symbols []SymbolInfo
|
||||
|
||||
switch strings.ToLower(exchange) {
|
||||
exchangeLower := strings.ToLower(exchange)
|
||||
switch exchangeLower {
|
||||
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
||||
// Fetch symbols from Hyperliquid
|
||||
client := hyperliquid.NewClient()
|
||||
ctx := context.Background()
|
||||
|
||||
// Get crypto perps from default dex
|
||||
if exchange == "hyperliquid" || exchange == "hyperliquid-xyz" {
|
||||
mids, err := client.GetAllMids(ctx)
|
||||
if err == nil {
|
||||
for symbol := range mids {
|
||||
// Skip spot tokens (start with @)
|
||||
if strings.HasPrefix(symbol, "@") {
|
||||
continue
|
||||
// hyperliquid-xyz returns the full USDC trading board in product order:
|
||||
// stocks → commodities → indices → forex → pre-IPO → crypto.
|
||||
if exchangeLower == "hyperliquid-xyz" || exchangeLower == "xyz" {
|
||||
xyzCoins, err := hyperliquid.GetPerpDexCoins(ctx, hyperliquid.XYZDex)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get Hyperliquid XYZ symbols", err)
|
||||
return
|
||||
}
|
||||
for _, coin := range xyzCoins {
|
||||
baseSymbol := strings.TrimPrefix(coin.Symbol, "xyz:")
|
||||
displayBase := hyperliquidXYZDisplayBase(baseSymbol)
|
||||
displaySymbol := displayBase + "-USDC"
|
||||
tradeSymbol := baseSymbol + "-USDC"
|
||||
symbols = append(symbols, SymbolInfo{
|
||||
Symbol: symbol,
|
||||
Name: symbol,
|
||||
Category: "crypto",
|
||||
Symbol: tradeSymbol,
|
||||
Display: displaySymbol,
|
||||
Name: displayBase,
|
||||
Category: hyperliquidXYZCategory(baseSymbol),
|
||||
Exchange: "hyperliquid-xyz",
|
||||
Volume24h: coin.Volume24h,
|
||||
MarkPrice: coin.MarkPrice,
|
||||
PrevDayPrice: coin.PrevDayPrice,
|
||||
Change24hPct: coin.Change24hPct,
|
||||
MaxLeverage: coin.MaxLeverage,
|
||||
SzDecimals: coin.SzDecimals,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get xyz dex symbols (stocks, forex, commodities)
|
||||
xyzMids, err := client.GetAllMidsXYZ(ctx)
|
||||
if err == nil {
|
||||
for symbol := range xyzMids {
|
||||
// Remove xyz: prefix for display
|
||||
displaySymbol := strings.TrimPrefix(symbol, "xyz:")
|
||||
category := "stock"
|
||||
if displaySymbol == "GOLD" || displaySymbol == "SILVER" {
|
||||
category = "commodity"
|
||||
} else if displaySymbol == "EUR" || displaySymbol == "JPY" {
|
||||
category = "forex"
|
||||
} else if displaySymbol == "XYZ100" {
|
||||
category = "index"
|
||||
// Crypto perps are shown last; only include them on the combined Hyperliquid board.
|
||||
if exchangeLower == "hyperliquid" || exchangeLower == "hyperliquid-xyz" {
|
||||
coins, err := hyperliquid.GetProvider().GetAllCoins(ctx)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get Hyperliquid symbols", err)
|
||||
return
|
||||
}
|
||||
for _, coin := range coins {
|
||||
symbols = append(symbols, SymbolInfo{
|
||||
Symbol: displaySymbol,
|
||||
Name: displaySymbol,
|
||||
Category: category,
|
||||
Symbol: coin.Symbol,
|
||||
Display: coin.Symbol,
|
||||
Name: coin.Symbol,
|
||||
Category: "crypto",
|
||||
Exchange: "hyperliquid",
|
||||
Volume24h: coin.Volume24h,
|
||||
MarkPrice: coin.MarkPrice,
|
||||
PrevDayPrice: coin.PrevDayPrice,
|
||||
Change24hPct: coin.Change24hPct,
|
||||
MaxLeverage: coin.MaxLeverage,
|
||||
SzDecimals: coin.SzDecimals,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -384,6 +531,15 @@ func (s *Server) handleSymbols(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
sort.SliceStable(symbols, func(i, j int) bool {
|
||||
ci := hyperliquidCategoryOrder(symbols[i].Category)
|
||||
cj := hyperliquidCategoryOrder(symbols[j].Category)
|
||||
if ci != cj {
|
||||
return ci < cj
|
||||
}
|
||||
return symbols[i].Volume24h > symbols[j].Volume24h
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"exchange": exchange,
|
||||
"symbols": symbols,
|
||||
|
||||
@@ -26,6 +26,7 @@ type beginnerOnboardingResponse struct {
|
||||
DefaultModel string `json:"default_model"`
|
||||
ConfiguredModelID string `json:"configured_model_id"`
|
||||
BalanceUSDC string `json:"balance_usdc"`
|
||||
BalanceStatus string `json:"balance_status,omitempty"`
|
||||
EnvSaved bool `json:"env_saved"`
|
||||
EnvPath string `json:"env_path,omitempty"`
|
||||
ReusedExisting bool `json:"reused_existing"`
|
||||
@@ -36,10 +37,22 @@ type currentBeginnerWalletResponse struct {
|
||||
Found bool `json:"found"`
|
||||
Address string `json:"address,omitempty"`
|
||||
BalanceUSDC string `json:"balance_usdc,omitempty"`
|
||||
BalanceStatus string `json:"balance_status,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Claw402Status string `json:"claw402_status"`
|
||||
}
|
||||
|
||||
// queryBeginnerWalletBalance returns the wallet balance plus a status flag so
|
||||
// the UI can distinguish "RPC unreachable" from a genuinely empty wallet.
|
||||
// Uses the 30s cache — safe for UI polling.
|
||||
func queryBeginnerWalletBalance(address string) (balanceUSDC string, balanceStatus string) {
|
||||
balance, err := wallet.QueryUSDCBalanceCached(address)
|
||||
if err != nil {
|
||||
return "", "unknown"
|
||||
}
|
||||
return fmt.Sprintf("%.2f", balance), "ok"
|
||||
}
|
||||
|
||||
func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
@@ -72,6 +85,7 @@ func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
|
||||
os.Setenv("CLAW402_DEFAULT_MODEL", payment.DefaultClaw402Model)
|
||||
|
||||
envSaved, envPath, envErr := persistBeginnerWalletEnv(privateKey, address)
|
||||
balanceUSDC, balanceStatus := queryBeginnerWalletBalance(address)
|
||||
resp := beginnerOnboardingResponse{
|
||||
Address: address,
|
||||
PrivateKey: privateKey,
|
||||
@@ -80,7 +94,8 @@ func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
|
||||
Provider: "claw402",
|
||||
DefaultModel: payment.DefaultClaw402Model,
|
||||
ConfiguredModelID: configuredModelID,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
BalanceUSDC: balanceUSDC,
|
||||
BalanceStatus: balanceStatus,
|
||||
EnvSaved: envSaved,
|
||||
EnvPath: envPath,
|
||||
ReusedExisting: reusedExisting,
|
||||
@@ -124,10 +139,12 @@ func (s *Server) handleCurrentBeginnerWallet(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
balanceUSDC, balanceStatus := queryBeginnerWalletBalance(address)
|
||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
||||
Found: true,
|
||||
Address: address,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
BalanceUSDC: balanceUSDC,
|
||||
BalanceStatus: balanceStatus,
|
||||
Source: "model",
|
||||
Claw402Status: claw402Status,
|
||||
})
|
||||
@@ -136,10 +153,12 @@ func (s *Server) handleCurrentBeginnerWallet(c *gin.Context) {
|
||||
|
||||
address := strings.TrimSpace(os.Getenv("CLAW402_WALLET_ADDRESS"))
|
||||
if address != "" {
|
||||
balanceUSDC, balanceStatus := queryBeginnerWalletBalance(address)
|
||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
||||
Found: true,
|
||||
Address: address,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
BalanceUSDC: balanceUSDC,
|
||||
BalanceStatus: balanceStatus,
|
||||
Source: "env",
|
||||
Claw402Status: claw402Status,
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
@@ -206,21 +207,43 @@ func (s *Server) handlePositionHistory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
if fullCfg, cfgErr := s.store.Trader().GetFullConfig(userID, traderID); cfgErr == nil && fullCfg.Exchange != nil {
|
||||
if syncErr := s.syncOrdersFromExchange(
|
||||
trader.GetUnderlyingTrader(),
|
||||
trader.GetID(),
|
||||
fullCfg.Exchange.ID,
|
||||
fullCfg.Exchange.ExchangeType,
|
||||
); syncErr != nil {
|
||||
logger.Infof("⚠️ Position history refresh sync skipped: %v", syncErr)
|
||||
}
|
||||
}
|
||||
|
||||
traderIDs := []string{trader.GetID()}
|
||||
var traderIDPatterns []string
|
||||
if strings.EqualFold(strings.TrimSpace(trader.GetName()), "NOFX Autopilot") && strings.TrimSpace(userID) != "" {
|
||||
// Older one-click launches created new Autopilot trader rows. When a row was
|
||||
// deleted, its closed position records remained under the old generated ID.
|
||||
// The generated Autopilot ID embeds userID + "claw402", so this safely
|
||||
// restores same-user history continuity without joining deleted rows.
|
||||
traderIDPatterns = append(traderIDPatterns, "%_"+userID+"_claw402_%")
|
||||
}
|
||||
|
||||
// Get closed positions
|
||||
positions, err := store.Position().GetClosedPositions(trader.GetID(), limit)
|
||||
positions, err := store.Position().GetClosedPositionsByTraderFilters(traderIDs, traderIDPatterns, limit)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get position history", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get statistics
|
||||
stats, _ := store.Position().GetFullStats(trader.GetID())
|
||||
stats, _ := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns, trader.GetInitialBalance())
|
||||
|
||||
// Get symbol stats
|
||||
symbolStats, _ := store.Position().GetSymbolStats(trader.GetID(), 10)
|
||||
symbolStats, _ := store.Position().GetSymbolStatsByTraderFilters(traderIDs, traderIDPatterns, 10)
|
||||
|
||||
// Get direction stats
|
||||
directionStats, _ := store.Position().GetDirectionStats(trader.GetID())
|
||||
directionStats, _ := store.Position().GetDirectionStatsByTraderFilters(traderIDs, traderIDPatterns)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"positions": positions,
|
||||
@@ -357,8 +380,8 @@ func (s *Server) handleOrderFills(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get fills for this order
|
||||
fills, err := store.Order().GetOrderFills(orderID)
|
||||
// Get fills for this order, scoped to the trader (ownership boundary).
|
||||
fills, err := store.Order().GetOrderFills(traderID, orderID)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get order fills", err)
|
||||
return
|
||||
|
||||
58
api/handler_stats_full.go
Normal file
58
api/handler_stats_full.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// handleStatisticsFull returns the full set of computed performance metrics for
|
||||
// a single trader: win rate, profit factor, Sharpe ratio, max drawdown, and the
|
||||
// average win/loss amounts. These are derived from the trader's CLOSED positions
|
||||
// via store.Position().GetFullStatsByTraderFilters — the same computation the
|
||||
// strategy engine feeds to the AI, so the dashboard and the model see identical
|
||||
// numbers.
|
||||
//
|
||||
// The existing GET /statistics endpoint only returns cycle/position counts; this
|
||||
// endpoint exposes the richer trade-quality metrics the terminal dashboard needs.
|
||||
func (s *Server) handleStatisticsFull(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
store := trader.GetStore()
|
||||
if store == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Aggregate across the trader's historical IDs exactly like the position
|
||||
// history endpoint (handler_order.go). One-click "NOFX Autopilot" relaunches
|
||||
// create fresh trader rows, but the closed positions stay under the old
|
||||
// generated IDs (which embed userID + "claw402"). Without this, a freshly
|
||||
// relaunched Autopilot would report only the current incarnation's trades
|
||||
// instead of its real lifetime history.
|
||||
userID := c.GetString("user_id")
|
||||
traderIDs := []string{trader.GetID()}
|
||||
var traderIDPatterns []string
|
||||
if strings.EqualFold(strings.TrimSpace(trader.GetName()), "NOFX Autopilot") && strings.TrimSpace(userID) != "" {
|
||||
traderIDPatterns = append(traderIDPatterns, "%_"+userID+"_claw402_%")
|
||||
}
|
||||
|
||||
stats, err := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns, trader.GetInitialBalance())
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get full statistics", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
@@ -14,6 +14,11 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
maxManualBTCETHLeverage = 20
|
||||
maxManualAltLeverage = 20
|
||||
)
|
||||
|
||||
// AI trader management related structures
|
||||
type CreateTraderRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
@@ -56,26 +61,44 @@ type UpdateTraderRequest struct {
|
||||
|
||||
func formatTraderCreationError(reason, nextStep string) string {
|
||||
if nextStep == "" {
|
||||
return fmt.Sprintf("这次未能创建机器人:%s。", reason)
|
||||
return fmt.Sprintf("Failed to create the bot this time: %s.", reason)
|
||||
}
|
||||
return fmt.Sprintf("这次未能创建机器人:%s。%s。", reason, nextStep)
|
||||
return fmt.Sprintf("Failed to create the bot this time: %s. %s.", reason, nextStep)
|
||||
}
|
||||
|
||||
func traderCreationRequestError(reason string) string {
|
||||
return formatTraderCreationError(reason, "请检查你刚刚填写的内容后,再重新提交")
|
||||
return formatTraderCreationError(reason, "Please check the information you just entered and submit again")
|
||||
}
|
||||
|
||||
func validateTraderLeverageRange(btcEthLeverage, altcoinLeverage int) (string, string) {
|
||||
if btcEthLeverage < 0 || btcEthLeverage > maxManualBTCETHLeverage {
|
||||
return traderCreationRequestError("BTC/ETH leverage must be between 1x and 20x"), "trader.create.invalid_btc_eth_leverage"
|
||||
}
|
||||
if altcoinLeverage < 0 || altcoinLeverage > maxManualAltLeverage {
|
||||
return traderCreationRequestError("Altcoin leverage must be between 1x and 20x"), "trader.create.invalid_altcoin_leverage"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func isSupportedTraderSymbol(symbol string) bool {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(symbol))
|
||||
if normalized == "" {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(normalized, "USDT") || strings.HasSuffix(normalized, "-USDC") || strings.HasPrefix(normalized, "XYZ:")
|
||||
}
|
||||
|
||||
func exchangeDisplayName(exchange *store.Exchange) string {
|
||||
if exchange == nil {
|
||||
return "所选交易所账户"
|
||||
return "the selected exchange account"
|
||||
}
|
||||
if exchange.AccountName != "" {
|
||||
return fmt.Sprintf("%s(%s)", exchange.Name, exchange.AccountName)
|
||||
return fmt.Sprintf("%s (%s)", exchange.Name, exchange.AccountName)
|
||||
}
|
||||
if exchange.Name != "" {
|
||||
return exchange.Name
|
||||
}
|
||||
return "所选交易所账户"
|
||||
return "the selected exchange account"
|
||||
}
|
||||
|
||||
func missingExchangeFields(exchange *store.Exchange) []string {
|
||||
@@ -104,10 +127,10 @@ func missingExchangeFields(exchange *store.Exchange) []string {
|
||||
}
|
||||
case "hyperliquid":
|
||||
if exchange.APIKey == "" {
|
||||
missing = append(missing, "私钥")
|
||||
missing = append(missing, "Private Key")
|
||||
}
|
||||
if strings.TrimSpace(exchange.HyperliquidWalletAddr) == "" {
|
||||
missing = append(missing, "钱包地址")
|
||||
missing = append(missing, "Wallet Address")
|
||||
}
|
||||
case "aster":
|
||||
if strings.TrimSpace(exchange.AsterUser) == "" {
|
||||
@@ -121,7 +144,7 @@ func missingExchangeFields(exchange *store.Exchange) []string {
|
||||
}
|
||||
case "lighter":
|
||||
if strings.TrimSpace(exchange.LighterWalletAddr) == "" {
|
||||
missing = append(missing, "钱包地址")
|
||||
missing = append(missing, "Wallet Address")
|
||||
}
|
||||
if exchange.LighterAPIKeyPrivateKey == "" {
|
||||
missing = append(missing, "API Key Private Key")
|
||||
@@ -145,21 +168,21 @@ func mapStringPairs(kv ...string) map[string]string {
|
||||
|
||||
func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string, map[string]string) {
|
||||
if exchange == nil {
|
||||
return formatTraderCreationError("还没有找到你选择的交易所账户", "请前往「设置 > 交易所配置」先添加一个可用账户,再回来创建机器人"),
|
||||
return formatTraderCreationError("The exchange account you selected was not found", "Please go to \"Settings > Exchange Config\" and add an available account first, then come back to create the bot"),
|
||||
"trader.create.exchange_not_found", nil
|
||||
}
|
||||
if !exchange.Enabled {
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」目前处于未启用状态", exchangeDisplayName(exchange)),
|
||||
"请前往「设置 > 交易所配置」启用该账户后,再重新创建机器人",
|
||||
fmt.Sprintf("Exchange account \"%s\" is currently disabled", exchangeDisplayName(exchange)),
|
||||
"Please go to \"Settings > Exchange Config\" to enable this account, then create the bot again",
|
||||
), "trader.create.exchange_disabled", mapStringPairs("exchange_name", exchangeDisplayName(exchange))
|
||||
}
|
||||
|
||||
missing := missingExchangeFields(exchange)
|
||||
if len(missing) > 0 {
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」的配置还不完整,缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")),
|
||||
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
|
||||
fmt.Sprintf("The configuration for exchange account \"%s\" is incomplete, missing %s", exchangeDisplayName(exchange), strings.Join(missing, ", ")),
|
||||
"Please go to \"Settings > Exchange Config\" to complete the required information for this account, then create the bot again",
|
||||
), "trader.create.exchange_missing_fields", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"missing_fields", strings.Join(missing, ", "),
|
||||
@@ -171,8 +194,8 @@ func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string
|
||||
return "", "", nil
|
||||
default:
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
|
||||
"请改用当前版本支持的交易所账户后,再重新创建机器人",
|
||||
fmt.Sprintf("Exchange account \"%s\" uses type %s, which is not supported in the current version", exchangeDisplayName(exchange), exchange.ExchangeType),
|
||||
"Please switch to an exchange account supported by the current version, then create the bot again",
|
||||
), "trader.create.exchange_unsupported", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"exchange_type", exchange.ExchangeType,
|
||||
@@ -191,28 +214,28 @@ func classifyTraderSetupReason(reason string) (string, string) {
|
||||
switch {
|
||||
case strings.Contains(lower, "failed to parse strategy config"),
|
||||
strings.Contains(lower, "failed to parse strategy configuration"):
|
||||
return "trader.reason.strategy_config_invalid", "当前策略配置内容已损坏,系统暂时无法解析"
|
||||
return "trader.reason.strategy_config_invalid", "The current strategy configuration is corrupted and the system cannot parse it for now"
|
||||
case strings.Contains(lower, "has no strategy configured"):
|
||||
return "trader.reason.strategy_missing", "当前机器人缺少有效的交易策略配置"
|
||||
return "trader.reason.strategy_missing", "The current bot is missing a valid trading strategy configuration"
|
||||
case strings.Contains(lower, "failed to parse private key"),
|
||||
(strings.Contains(lower, "invalid hex character") && strings.Contains(lower, "private key")):
|
||||
return "trader.reason.private_key_invalid", "私钥格式不正确,系统无法识别"
|
||||
return "trader.reason.private_key_invalid", "The private key format is incorrect and the system cannot recognize it"
|
||||
case strings.Contains(lower, "failed to initialize hyperliquid trader"):
|
||||
return "trader.reason.hyperliquid_init_failed", "Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确"
|
||||
return "trader.reason.hyperliquid_init_failed", "Hyperliquid account initialization failed; please confirm the private key, main wallet address, and Agent Wallet configuration are correct"
|
||||
case strings.Contains(lower, "failed to initialize aster trader"):
|
||||
return "trader.reason.aster_init_failed", "Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确"
|
||||
return "trader.reason.aster_init_failed", "Aster account initialization failed; please confirm the Aster User, Signer, and private key are correct"
|
||||
case strings.Contains(lower, "failed to get meta information"):
|
||||
return "trader.reason.exchange_meta_unavailable", "系统暂时无法从交易所读取账户元信息"
|
||||
return "trader.reason.exchange_meta_unavailable", "The system cannot read account meta information from the exchange for now"
|
||||
case strings.Contains(lower, "security check failed") && strings.Contains(lower, "agent wallet balance too high"):
|
||||
return "trader.reason.hyperliquid_agent_balance_too_high", "Hyperliquid Agent Wallet 余额过高,不符合当前安全要求"
|
||||
return "trader.reason.hyperliquid_agent_balance_too_high", "The Hyperliquid Agent Wallet balance is too high and does not meet the current security requirements"
|
||||
case strings.Contains(lower, "failed to initialize account"):
|
||||
return "trader.reason.exchange_account_init_failed", "交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配"
|
||||
return "trader.reason.exchange_account_init_failed", "Exchange account initialization failed; please confirm the wallet address and API Key match"
|
||||
case strings.Contains(lower, "unsupported trading platform"):
|
||||
return "trader.reason.exchange_unsupported", "当前交易所类型暂不支持机器人初始化"
|
||||
return "trader.reason.exchange_unsupported", "The current exchange type does not support bot initialization"
|
||||
case strings.Contains(lower, "initial balance not set and unable to fetch balance from exchange"):
|
||||
return "trader.reason.exchange_balance_unavailable", "系统暂时无法从交易所读取账户余额"
|
||||
return "trader.reason.exchange_balance_unavailable", "The system cannot read the account balance from the exchange for now"
|
||||
case strings.Contains(lower, "timeout"), strings.Contains(lower, "no such host"), strings.Contains(lower, "connection refused"):
|
||||
return "trader.reason.exchange_service_unreachable", "系统暂时无法连接交易所服务"
|
||||
return "trader.reason.exchange_service_unreachable", "The system cannot connect to the exchange service for now"
|
||||
default:
|
||||
return "trader.reason.unknown", trimmed
|
||||
}
|
||||
@@ -247,54 +270,54 @@ func traderSetupReasonParams(err error, fallback string, kv ...string) map[strin
|
||||
|
||||
func describeTraderLoadError(traderName string, err error) string {
|
||||
if err == nil {
|
||||
return formatTraderCreationError("机器人配置虽然保存了,但运行实例没有成功初始化", "请检查模型、策略和交易所配置是否完整,然后再试一次")
|
||||
return formatTraderCreationError("The bot configuration was saved, but the runtime instance failed to initialize", "Please check that the model, strategy, and exchange configuration are complete, then try again")
|
||||
}
|
||||
|
||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
||||
if reason == "" {
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动", traderName),
|
||||
"请检查模型、策略和交易所配置是否完整,然后再试一次",
|
||||
fmt.Sprintf("Bot \"%s\" failed to start when initializing its runtime instance", traderName),
|
||||
"Please check that the model, strategy, and exchange configuration are complete, then try again",
|
||||
)
|
||||
}
|
||||
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动,原因是:%s", traderName, reason),
|
||||
"请检查模型、策略和交易所配置是否完整,然后再试一次",
|
||||
fmt.Sprintf("Bot \"%s\" failed to start when initializing its runtime instance, because: %s", traderName, reason),
|
||||
"Please check that the model, strategy, and exchange configuration are complete, then try again",
|
||||
)
|
||||
}
|
||||
|
||||
func describeTraderCreationWarning(traderName string, err error) string {
|
||||
if err == nil {
|
||||
return fmt.Sprintf("机器人「%s」已经保存,但当前还没有通过启动前校验。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName)
|
||||
return fmt.Sprintf("Bot \"%s\" has been saved, but it has not yet passed the pre-start validation. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName)
|
||||
}
|
||||
|
||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
||||
if reason == "" {
|
||||
return fmt.Sprintf("机器人「%s」已经保存,但当前暂时还不能启动。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName)
|
||||
return fmt.Sprintf("Bot \"%s\" has been saved, but it cannot start for now. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("机器人「%s」已经保存,但当前暂时还不能启动,原因是:%s。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName, reason)
|
||||
return fmt.Sprintf("Bot \"%s\" has been saved, but it cannot start for now, because: %s. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName, reason)
|
||||
}
|
||||
|
||||
func describeTraderStartError(traderName string, err error) string {
|
||||
if err == nil {
|
||||
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后,再重新点击启动。", traderName)
|
||||
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now. Please check the model, strategy, and exchange configuration, then click start again.", traderName)
|
||||
}
|
||||
|
||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
||||
if reason == "" {
|
||||
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后,再重新点击启动。", traderName)
|
||||
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now. Please check the model, strategy, and exchange configuration, then click start again.", traderName)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动,原因是:%s。请检查模型、策略和交易所配置后,再重新点击启动。", traderName, reason)
|
||||
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now, because: %s. Please check the model, strategy, and exchange configuration, then click start again.", traderName, reason)
|
||||
}
|
||||
|
||||
func formatTraderStartError(reason, nextStep string) string {
|
||||
if nextStep == "" {
|
||||
return fmt.Sprintf("这次未能启动机器人:%s。", reason)
|
||||
return fmt.Sprintf("Failed to start the bot this time: %s.", reason)
|
||||
}
|
||||
return fmt.Sprintf("这次未能启动机器人:%s。%s。", reason, nextStep)
|
||||
return fmt.Sprintf("Failed to start the bot this time: %s. %s.", reason, nextStep)
|
||||
}
|
||||
|
||||
// handleCreateTrader Create new AI trader
|
||||
@@ -302,28 +325,26 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
var req CreateTraderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("提交的信息不完整,或者格式不正确"), "trader.create.invalid_request", nil)
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("The submitted information is incomplete or has an invalid format"), "trader.create.invalid_request", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate leverage values
|
||||
if req.BTCETHLeverage < 0 || req.BTCETHLeverage > 50 {
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 50 倍之间"), "trader.create.invalid_btc_eth_leverage", nil)
|
||||
return
|
||||
}
|
||||
if req.AltcoinLeverage < 0 || req.AltcoinLeverage > 20 {
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage", nil)
|
||||
// Validate leverage values against the same limits exposed by manual user config.
|
||||
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
|
||||
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate trading symbol format
|
||||
// Validate trading symbol format. Hyperliquid xyz dex markets (stocks,
|
||||
// commodities, indices, FX, Pre-IPO) are user-facing SYMBOL-USDC pairs,
|
||||
// while standard crypto/perp markets keep the legacy USDT suffix format.
|
||||
if req.TradingSymbols != "" {
|
||||
symbols := strings.Split(req.TradingSymbols, ",")
|
||||
for _, symbol := range symbols {
|
||||
symbol = strings.TrimSpace(symbol)
|
||||
if symbol != "" && !strings.HasSuffix(strings.ToUpper(symbol), "USDT") {
|
||||
if !isSupportedTraderSymbol(symbol) {
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError(
|
||||
fmt.Sprintf("交易对 %s 的格式不正确,目前只支持以 USDT 结尾的合约交易对", symbol),
|
||||
fmt.Sprintf("The trading pair %s has an invalid format; only USDT perpetuals or Hyperliquid XYZ USDC instruments (SYMBOL-USDC) are currently supported", symbol),
|
||||
), "trader.create.invalid_symbol", mapStringPairs("symbol", symbol))
|
||||
return
|
||||
}
|
||||
@@ -333,32 +354,32 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
model, err := s.store.AIModel().Get(userID, req.AIModelID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("还没有找到你选择的 AI 模型", "请前往「设置 > 模型配置」先添加并启用一个可用模型,再回来创建机器人"), "trader.create.model_not_found", nil)
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("The AI model you selected was not found", "Please go to \"Settings > Model Config\" to add and enable an available model first, then come back to create the bot"), "trader.create.model_not_found", nil)
|
||||
return
|
||||
}
|
||||
SafeError(c, http.StatusInternalServerError,
|
||||
formatTraderCreationError("暂时无法读取你的 AI 模型配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
|
||||
formatTraderCreationError("Unable to read your AI model configuration for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
if !model.Enabled {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
||||
fmt.Sprintf("AI 模型「%s」目前还没有启用", model.Name),
|
||||
"请前往「设置 > 模型配置」启用它后,再重新创建机器人",
|
||||
fmt.Sprintf("AI model \"%s\" is not enabled yet", model.Name),
|
||||
"Please go to \"Settings > Model Config\" to enable it, then create the bot again",
|
||||
), "trader.create.model_disabled", mapStringPairs("model_name", model.Name))
|
||||
return
|
||||
}
|
||||
if model.APIKey == "" {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
||||
fmt.Sprintf("AI 模型「%s」缺少 API Key 或支付凭证", model.Name),
|
||||
"请前往「设置 > 模型配置」补全模型凭证后,再重新创建机器人",
|
||||
fmt.Sprintf("AI model \"%s\" is missing an API Key or payment credentials", model.Name),
|
||||
"Please go to \"Settings > Model Config\" to complete the model credentials, then create the bot again",
|
||||
), "trader.create.model_missing_credentials", mapStringPairs("model_name", model.Name))
|
||||
return
|
||||
}
|
||||
|
||||
if req.StrategyID == "" {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("你还没有选择交易策略", "请先选择一个策略,再继续创建机器人"), "trader.create.strategy_required", nil)
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("You have not selected a trading strategy yet", "Please select a strategy first, then continue creating the bot"), "trader.create.strategy_required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -366,11 +387,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
_, err = s.store.Strategy().Get(userID, req.StrategyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("你选择的策略不存在,或者已经被删除了", "请重新选择一个可用策略后,再继续创建机器人"), "trader.create.strategy_not_found", nil)
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("The strategy you selected does not exist or has been deleted", "Please select another available strategy, then continue creating the bot"), "trader.create.strategy_not_found", nil)
|
||||
return
|
||||
}
|
||||
SafeError(c, http.StatusInternalServerError,
|
||||
formatTraderCreationError("暂时无法读取你选择的策略配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
|
||||
formatTraderCreationError("Unable to read the strategy configuration you selected for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
|
||||
err,
|
||||
)
|
||||
return
|
||||
@@ -413,8 +434,10 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
|
||||
// Set scan interval default value
|
||||
scanIntervalMinutes := req.ScanIntervalMinutes
|
||||
if scanIntervalMinutes < 3 {
|
||||
scanIntervalMinutes = 3 // Default 3 minutes, not allowed to be less than 3
|
||||
if scanIntervalMinutes <= 0 {
|
||||
scanIntervalMinutes = 15
|
||||
} else if scanIntervalMinutes < 3 {
|
||||
scanIntervalMinutes = 3 // Explicit values below 3 minutes are clamped to the minimum.
|
||||
}
|
||||
|
||||
// Query exchange actual balance, override user input
|
||||
@@ -422,7 +445,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
exchanges, err := s.store.Exchange().List(userID)
|
||||
if err != nil {
|
||||
SafeError(c, http.StatusInternalServerError,
|
||||
formatTraderCreationError("暂时无法读取你的交易所配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
|
||||
formatTraderCreationError("Unable to read your exchange configuration for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
|
||||
err,
|
||||
)
|
||||
return
|
||||
@@ -446,9 +469,9 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
|
||||
if createErr != nil {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」没有通过初始化校验,原因是:%s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "配置校验未通过"))),
|
||||
"请前往「设置 > 交易所配置」检查这个账户的密钥、地址和账户信息是否填写正确",
|
||||
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "配置校验未通过",
|
||||
fmt.Sprintf("Exchange account \"%s\" did not pass initialization validation, because: %s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "Configuration validation failed"))),
|
||||
"Please go to \"Settings > Exchange Config\" to check whether this account's keys, address, and account information are entered correctly",
|
||||
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "Configuration validation failed",
|
||||
"exchange_name", exchangeDisplayName(exchangeCfg),
|
||||
))
|
||||
return
|
||||
@@ -498,9 +521,9 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
err = s.store.Trader().Create(traderRecord)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to create trader: %v", err)
|
||||
publicMsg := SanitizeError(err, formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次"))
|
||||
publicMsg := SanitizeError(err, formatTraderCreationError("The bot configuration was not saved successfully", "Please check the name, model, strategy, and exchange configuration, then try again"))
|
||||
statusCode := http.StatusBadRequest
|
||||
if publicMsg == formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次") {
|
||||
if publicMsg == formatTraderCreationError("The bot configuration was not saved successfully", "Please check the name, model, strategy, and exchange configuration, then try again") {
|
||||
statusCode = http.StatusInternalServerError
|
||||
}
|
||||
SafeError(c, statusCode, publicMsg, err)
|
||||
@@ -574,6 +597,11 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
|
||||
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Set default values
|
||||
isCrossMargin := existingTrader.IsCrossMargin // Keep original value
|
||||
if req.IsCrossMargin != nil {
|
||||
@@ -703,6 +731,9 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
"trader_id": traderID,
|
||||
"trader_name": req.Name,
|
||||
"ai_model": req.AIModelID,
|
||||
// A running trader is restarted with the new config (async above), so
|
||||
// report it as running — callers must not fire a redundant start.
|
||||
"is_running": wasRunning,
|
||||
"message": "Trader updated successfully",
|
||||
})
|
||||
}
|
||||
@@ -751,12 +782,23 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
traderName = fullCfg.Trader.Name
|
||||
}
|
||||
|
||||
if fullCfg != nil && fullCfg.Exchange != nil && fullCfg.Exchange.ExchangeType == "hyperliquid" && !fullCfg.Exchange.HyperliquidBuilderApproved {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError(
|
||||
fmt.Sprintf("The Hyperliquid trading authorization for bot \"%s\" is not yet complete", traderName),
|
||||
"Please reconnect the Hyperliquid wallet and complete the trading authorization, then start the bot",
|
||||
), "trader.start.hyperliquid_builder_not_approved", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if trader exists in memory and if it's running
|
||||
existingTrader, _ := s.traderManager.GetTrader(traderID)
|
||||
if existingTrader != nil {
|
||||
status := existingTrader.GetStatus()
|
||||
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader is already running"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Trader is already running",
|
||||
"error_key": "trader.start.already_running",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Trader exists but is stopped - remove from memory to reload fresh config
|
||||
@@ -782,25 +824,25 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
}
|
||||
// Check AI model
|
||||
if fullCfg.AIModel == nil {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的 AI 模型不存在", "请前往「设置 > 模型配置」检查后,再重新点击启动"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError("The AI model associated with this bot does not exist", "Please go to \"Settings > Model Config\" to check, then click start again"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
|
||||
return
|
||||
}
|
||||
if !fullCfg.AIModel.Enabled {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError(
|
||||
fmt.Sprintf("机器人「%s」关联的 AI 模型「%s」目前还没有启用", traderName, fullCfg.AIModel.Name),
|
||||
"请前往「设置 > 模型配置」启用它后,再重新点击启动",
|
||||
fmt.Sprintf("The AI model \"%s\" associated with bot \"%s\" is not enabled yet", fullCfg.AIModel.Name, traderName),
|
||||
"Please go to \"Settings > Model Config\" to enable it, then click start again",
|
||||
), "trader.start.model_disabled", mapStringPairs("trader_name", traderName, "model_name", fullCfg.AIModel.Name))
|
||||
return
|
||||
}
|
||||
// Check exchange
|
||||
if fullCfg.Exchange == nil {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的交易所账户不存在", "请前往「设置 > 交易所配置」检查后,再重新点击启动"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError("The exchange account associated with this bot does not exist", "Please go to \"Settings > Exchange Config\" to check, then click start again"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
|
||||
return
|
||||
}
|
||||
if !fullCfg.Exchange.Enabled {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError(
|
||||
fmt.Sprintf("机器人「%s」关联的交易所账户「%s」目前还没有启用", traderName, exchangeDisplayName(fullCfg.Exchange)),
|
||||
"请前往「设置 > 交易所配置」启用它后,再重新点击启动",
|
||||
fmt.Sprintf("The exchange account \"%s\" associated with bot \"%s\" is not enabled yet", exchangeDisplayName(fullCfg.Exchange), traderName),
|
||||
"Please go to \"Settings > Exchange Config\" to enable it, then click start again",
|
||||
), "trader.start.exchange_disabled", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
|
||||
return
|
||||
}
|
||||
@@ -814,6 +856,24 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Server-side launch gate: the trader cannot function without a funded AI
|
||||
// wallet and a ready exchange account, so verify both before the run loop
|
||||
// starts. `?force=true` skips the gate for deliberate manual overrides.
|
||||
if c.Query("force") != "true" {
|
||||
// strategyRequired=false: a trader that loaded into memory necessarily
|
||||
// has a valid strategy (the manager refuses to load without one), so the
|
||||
// preflight strategy check would be redundant here.
|
||||
preflight := s.runLaunchPreflight(userID, fullCfg.AIModel, fullCfg.Exchange, fullCfg.Strategy, false)
|
||||
if !preflight.Ready {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": formatTraderStartError(preflight.Summary(), "Complete the failing checks, then start the bot again"),
|
||||
"error_key": "trader.start.preflight_failed",
|
||||
"preflight": preflight,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Start trader
|
||||
go func() {
|
||||
logger.Infof("▶️ Starting trader %s (%s)", traderID, trader.GetName())
|
||||
|
||||
@@ -267,8 +267,12 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
||||
|
||||
logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result)
|
||||
|
||||
// Record order to database (for chart markers and history)
|
||||
// Backfill the just-closed fill immediately. Manual closes may happen while
|
||||
// the bot runtime is stopped, so the background OrderSync loop is not enough.
|
||||
if syncErr := s.syncOrdersAfterManualClose(tempTrader, traderID, exchangeCfg.ID, exchangeCfg.ExchangeType); syncErr != nil {
|
||||
logger.Infof(" ⚠️ Manual close sync failed: %v", syncErr)
|
||||
s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Position closed successfully",
|
||||
@@ -278,6 +282,49 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) syncOrdersFromExchange(exchangeTrader trader.Trader, traderID, exchangeID, exchangeType string) error {
|
||||
switch t := exchangeTrader.(type) {
|
||||
case *binance.FuturesTrader:
|
||||
return t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, s.store)
|
||||
case *hyperliquidtrader.HyperliquidTrader:
|
||||
return t.SyncOrdersFromHyperliquid(traderID, exchangeID, exchangeType, s.store)
|
||||
case *aster.AsterTrader:
|
||||
return t.SyncOrdersFromAster(traderID, exchangeID, exchangeType, s.store)
|
||||
case *bybit.BybitTrader:
|
||||
return t.SyncOrdersFromBybit(traderID, exchangeID, exchangeType, s.store)
|
||||
case *okx.OKXTrader:
|
||||
return t.SyncOrdersFromOKX(traderID, exchangeID, exchangeType, s.store)
|
||||
case *bitget.BitgetTrader:
|
||||
return t.SyncOrdersFromBitget(traderID, exchangeID, exchangeType, s.store)
|
||||
case *gate.GateTrader:
|
||||
return t.SyncOrdersFromGate(traderID, exchangeID, exchangeType, s.store)
|
||||
case *kucoin.KuCoinTrader:
|
||||
return t.SyncOrdersFromKuCoin(traderID, exchangeID, exchangeType, s.store)
|
||||
case *lighter.LighterTraderV2:
|
||||
return t.SyncOrdersFromLighter(traderID, exchangeID, exchangeType, s.store)
|
||||
default:
|
||||
return fmt.Errorf("order sync is not available for exchange type %s", exchangeType)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) syncOrdersAfterManualClose(exchangeTrader trader.Trader, traderID, exchangeID, exchangeType string) error {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= 4; attempt++ {
|
||||
if attempt > 1 {
|
||||
time.Sleep(time.Duration(attempt-1) * 500 * time.Millisecond)
|
||||
}
|
||||
if err := s.syncOrdersFromExchange(exchangeTrader, traderID, exchangeID, exchangeType); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return fmt.Errorf("manual close sync did not run")
|
||||
}
|
||||
|
||||
// recordClosePositionOrder Record close position order to database (Lighter version - direct FILLED status)
|
||||
func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) {
|
||||
// Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates
|
||||
|
||||
28
api/handler_trader_symbol_test.go
Normal file
28
api/handler_trader_symbol_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsSupportedTraderSymbol(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
want bool
|
||||
}{
|
||||
{name: "legacy USDT perp", symbol: "BTCUSDT", want: true},
|
||||
{name: "legacy USDT perp lowercase", symbol: "ethusdt", want: true},
|
||||
{name: "Hyperliquid xyz stock USDC pair", symbol: "SMSN-USDC", want: true},
|
||||
{name: "Hyperliquid xyz commodity USDC pair", symbol: "GOLD-USDC", want: true},
|
||||
{name: "legacy internal xyz prefix still accepted", symbol: "xyz:SMSN", want: true},
|
||||
{name: "empty slot ignored", symbol: " ", want: true},
|
||||
{name: "bare stock without xyz prefix rejected", symbol: "SMSN", want: false},
|
||||
{name: "unknown non-USDT pair rejected", symbol: "BTCUSD", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isSupportedTraderSymbol(tt.symbol); got != tt.want {
|
||||
t.Fatalf("isSupportedTraderSymbol(%q) = %v, want %v", tt.symbol, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
17
api/handler_trader_test.go
Normal file
17
api/handler_trader_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateTraderLeverageRangeMatchesManualLimits(t *testing.T) {
|
||||
if msg, code := validateTraderLeverageRange(20, 20); msg != "" || code != "" {
|
||||
t.Fatalf("expected 20/20 leverage to be accepted, got msg=%q code=%q", msg, code)
|
||||
}
|
||||
|
||||
if msg, code := validateTraderLeverageRange(21, 20); msg == "" || code != "trader.create.invalid_btc_eth_leverage" {
|
||||
t.Fatalf("expected BTC/ETH leverage > 20 to be rejected, got msg=%q code=%q", msg, code)
|
||||
}
|
||||
|
||||
if msg, code := validateTraderLeverageRange(20, 21); msg == "" || code != "trader.create.invalid_altcoin_leverage" {
|
||||
t.Fatalf("expected altcoin leverage > 20 to be rejected, got msg=%q code=%q", msg, code)
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
|
||||
@@ -102,9 +102,11 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Adopt orphan records from previous account (e.g. after account reset)
|
||||
// This preserves wallet keys and exchange configs so funds are not lost.
|
||||
s.adoptOrphanRecords(userID)
|
||||
// NOTE: Orphan record adoption was removed for security reasons. Previously,
|
||||
// after a reset-account call, any new user would inherit the prior owner's
|
||||
// wallet keys and exchange API credentials — a catastrophic IDOR/takeover
|
||||
// path. Operators who need to migrate credentials across users must do so
|
||||
// explicitly via export/import, never via implicit adoption on registration.
|
||||
|
||||
// Generate JWT token
|
||||
token, err := auth.GenerateJWT(user.ID, user.Email)
|
||||
@@ -127,6 +129,13 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// dummyPasswordHash is a valid bcrypt hash of a throwaway value. It is compared
|
||||
// against when the submitted email does not exist so that login takes roughly
|
||||
// the same time whether or not the account exists — closing the timing side
|
||||
// channel that would otherwise let an attacker enumerate valid emails (a fast
|
||||
// "no such user" vs. a slow bcrypt compare). It is not a secret.
|
||||
const dummyPasswordHash = "$2a$10$0iF0bCoQLJ6Ph1bF.MXwHOW.IMTxQjeEW.w38dctRQAB2kwB6ga1q"
|
||||
|
||||
// handleLogin Handle user login request
|
||||
func (s *Server) handleLogin(c *gin.Context) {
|
||||
var req struct {
|
||||
@@ -142,6 +151,9 @@ func (s *Server) handleLogin(c *gin.Context) {
|
||||
// Get user information
|
||||
user, err := s.store.User().GetByEmail(req.Email)
|
||||
if err != nil {
|
||||
// Perform a dummy comparison so the response time does not reveal
|
||||
// whether the email exists (anti user-enumeration), then fail uniformly.
|
||||
auth.CheckPassword(req.Password, dummyPasswordHash)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
|
||||
return
|
||||
}
|
||||
@@ -189,86 +201,14 @@ func (s *Server) handleChangePassword(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
|
||||
}
|
||||
|
||||
// handleResetPassword Reset password via email and new password
|
||||
func (s *Server) handleResetPassword(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Query user
|
||||
user, err := s.store.User().GetByEmail(req.Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Email does not exist"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new password hash
|
||||
newPasswordHash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password processing failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update password
|
||||
err = s.store.User().UpdatePassword(user.ID, newPasswordHash)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password update failed"})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("✓ User %s password has been reset", user.Email)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password reset successful, please login with new password"})
|
||||
}
|
||||
|
||||
// handleResetAccount clears user authentication data so the system returns to
|
||||
// uninitialized state for re-registration. Wallet keys (ai_models) are preserved
|
||||
// so funds are not lost — they will be adopted by the new account during onboarding.
|
||||
func (s *Server) handleResetAccount(c *gin.Context) {
|
||||
err := s.store.Transaction(func(tx *gorm.DB) error {
|
||||
// Delete traders and strategies (config, not funds)
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{})
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{})
|
||||
// Delete users — ai_models and exchanges are intentionally kept
|
||||
// so wallet private keys and exchange configs survive re-registration
|
||||
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete users: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to reset account", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("✓ User accounts cleared (wallets preserved) — system reset to uninitialized")
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Account reset successful, you can now register a new account"})
|
||||
}
|
||||
|
||||
// adoptOrphanRecords re-assigns ai_models and exchanges whose user_id no longer
|
||||
// exists in the users table. This happens after account reset so the new user
|
||||
// inherits the previous wallet keys and exchange configurations.
|
||||
func (s *Server) adoptOrphanRecords(newUserID string) {
|
||||
db := s.store.GormDB()
|
||||
result := db.Model(&store.AIModel{}).
|
||||
Where("user_id NOT IN (SELECT id FROM users)").
|
||||
Update("user_id", newUserID)
|
||||
if result.RowsAffected > 0 {
|
||||
logger.Infof("✓ Adopted %d orphan ai_model(s) for new user %s", result.RowsAffected, newUserID)
|
||||
}
|
||||
|
||||
result = db.Model(&store.Exchange{}).
|
||||
Where("user_id NOT IN (SELECT id FROM users)").
|
||||
Update("user_id", newUserID)
|
||||
if result.RowsAffected > 0 {
|
||||
logger.Infof("✓ Adopted %d orphan exchange(s) for new user %s", result.RowsAffected, newUserID)
|
||||
}
|
||||
}
|
||||
// NOTE: Password and account recovery used to live here as the public,
|
||||
// unauthenticated handlers handleResetPassword / handleResetAccount. They were
|
||||
// removed because an unauthenticated recovery endpoint is a remotely
|
||||
// exploitable auth-bypass on any public-facing deployment: the confirm phrase
|
||||
// was embedded in the frontend (and echoed back by the API), so it was friction
|
||||
// rather than authentication. Recovery now lives in the local CLI
|
||||
// (`nofx reset-password` / `nofx reset-account`, see cli.go), which requires
|
||||
// shell access to the host — something a remote attacker does not have.
|
||||
|
||||
// initUserDefaultConfigs Initialize default configs for new user
|
||||
func (s *Server) initUserDefaultConfigs(userID string, lang string) error {
|
||||
@@ -285,23 +225,17 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
name, description string
|
||||
}
|
||||
type strategyLocale struct {
|
||||
balanced, conservative, aggressive strategyI18n
|
||||
defaultStrategy strategyI18n
|
||||
}
|
||||
locales := map[string]strategyLocale{
|
||||
"zh": {
|
||||
balanced: strategyI18n{"均衡策略", "系统默认策略。均衡风险收益,适合大多数市场环境。5倍杠杆,最多3个仓位。"},
|
||||
conservative: strategyI18n{"稳健策略", "系统默认策略。低杠杆保守操作,优先保护本金。3倍杠杆,专注主流资产。"},
|
||||
aggressive: strategyI18n{"积极策略", "系统默认策略。高杠杆主动交易,更广泛的币种选择,适合经验丰富的交易者。10倍杠杆,最多5个仓位。"},
|
||||
defaultStrategy: strategyI18n{"NOFX Claw402 Auto Strategy", "The only built-in strategy: read the Claw402.ai board each cycle, fetch Signal Lab and cost/liquidation heatmap per candidate, then decide with raw candles."},
|
||||
},
|
||||
"en": {
|
||||
balanced: strategyI18n{"Balanced Strategy", "System default strategy. Balanced risk-reward, suitable for most market conditions. 5x leverage, up to 3 positions."},
|
||||
conservative: strategyI18n{"Conservative Strategy", "System default strategy. Low-leverage conservative trading, capital preservation first. 3x leverage, focused on major assets."},
|
||||
aggressive: strategyI18n{"Aggressive Strategy", "System default strategy. High-leverage active trading, wider asset selection, for experienced traders. 10x leverage, up to 5 positions."},
|
||||
defaultStrategy: strategyI18n{"NOFX Claw402 Auto Strategy", "The only built-in strategy: read the Claw402.ai board each cycle, fetch Signal Lab and cost/liquidation heatmap per candidate, then decide with raw candles."},
|
||||
},
|
||||
"id": {
|
||||
balanced: strategyI18n{"Strategi Seimbang", "Strategi default sistem. Risiko-reward seimbang, cocok untuk sebagian besar kondisi pasar. Leverage 5x, hingga 3 posisi."},
|
||||
conservative: strategyI18n{"Strategi Konservatif", "Strategi default sistem. Trading konservatif leverage rendah, utamakan perlindungan modal. Leverage 3x, fokus aset utama."},
|
||||
aggressive: strategyI18n{"Strategi Agresif", "Strategi default sistem. Trading aktif leverage tinggi, pilihan aset lebih luas, untuk trader berpengalaman. Leverage 10x, hingga 5 posisi."},
|
||||
defaultStrategy: strategyI18n{"Strategi Otomatis NOFX Claw402", "Satu strategi bawaan: membaca papan Claw402.ai, mengambil Signal Lab dan heatmap biaya/likuidasi per kandidat, lalu memutuskan dengan candle mentah."},
|
||||
},
|
||||
}
|
||||
locale, ok := locales[lang]
|
||||
@@ -316,45 +250,45 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
applyConfig func(*store.StrategyConfig)
|
||||
}
|
||||
|
||||
setClaw402Strategy := func(c *store.StrategyConfig) {
|
||||
c.CoinSource.SourceType = "vergex_signal"
|
||||
c.CoinSource.StaticCoins = nil
|
||||
c.CoinSource.UseAI500 = false
|
||||
c.CoinSource.UseOITop = false
|
||||
c.CoinSource.UseOILow = false
|
||||
c.CoinSource.UseHyperAll = false
|
||||
c.CoinSource.UseHyperMain = false
|
||||
c.CoinSource.HyperRankCategory = "all"
|
||||
c.CoinSource.VergexLimit = 10
|
||||
c.CoinSource.VergexMarketType = "all"
|
||||
c.CoinSource.VergexChain = "hyperliquid"
|
||||
c.RiskControl.MaxPositions = 2
|
||||
c.RiskControl.BTCETHMaxLeverage = 5
|
||||
c.RiskControl.AltcoinMaxLeverage = 5
|
||||
// Few, concentrated positions held for big moves. 5x leverage so a
|
||||
// wide (-5%) stop is survivable rather than an instant liquidation;
|
||||
// 2 positions × 2.5x = 5x total notional (full margin, ~20% cushion).
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = 2.5
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 2.5
|
||||
c.RiskControl.MaxMarginUsage = 1.0
|
||||
c.RiskControl.MinConfidence = 78
|
||||
c.RiskControl.MinRiskRewardRatio = 3.0
|
||||
c.Indicators.Klines.PrimaryTimeframe = "15m"
|
||||
c.Indicators.Klines.PrimaryCount = 30
|
||||
c.Indicators.Klines.LongerTimeframe = ""
|
||||
c.Indicators.Klines.LongerCount = 0
|
||||
c.Indicators.Klines.EnableMultiTimeframe = false
|
||||
c.Indicators.Klines.SelectedTimeframes = []string{"15m"}
|
||||
c.Indicators.EnableRawKlines = true
|
||||
}
|
||||
|
||||
definitions := []strategyDef{
|
||||
{
|
||||
name: locale.balanced.name,
|
||||
description: locale.balanced.description,
|
||||
name: locale.defaultStrategy.name,
|
||||
description: locale.defaultStrategy.description,
|
||||
isActive: true,
|
||||
applyConfig: func(c *store.StrategyConfig) {
|
||||
// Uses default config as-is
|
||||
},
|
||||
},
|
||||
{
|
||||
name: locale.conservative.name,
|
||||
description: locale.conservative.description,
|
||||
isActive: false,
|
||||
applyConfig: func(c *store.StrategyConfig) {
|
||||
c.RiskControl.BTCETHMaxLeverage = 3
|
||||
c.RiskControl.AltcoinMaxLeverage = 3
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = 3.0
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 0.5
|
||||
c.RiskControl.MinConfidence = 80
|
||||
c.RiskControl.MinRiskRewardRatio = 4.0
|
||||
c.Indicators.Klines.SelectedTimeframes = []string{"15m", "1h", "4h"}
|
||||
c.Indicators.Klines.PrimaryTimeframe = "15m"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: locale.aggressive.name,
|
||||
description: locale.aggressive.description,
|
||||
isActive: false,
|
||||
applyConfig: func(c *store.StrategyConfig) {
|
||||
c.RiskControl.BTCETHMaxLeverage = 10
|
||||
c.RiskControl.AltcoinMaxLeverage = 7
|
||||
c.RiskControl.MaxPositions = 5
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 2.0
|
||||
c.RiskControl.MinConfidence = 70
|
||||
c.CoinSource.AI500Limit = 5
|
||||
c.CoinSource.UseOITop = true
|
||||
c.CoinSource.OITopLimit = 5
|
||||
c.Indicators.Klines.SelectedTimeframes = []string{"3m", "15m", "1h"}
|
||||
c.Indicators.Klines.PrimaryTimeframe = "3m"
|
||||
setClaw402Strategy(c)
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -370,6 +304,7 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
for _, def := range definitions {
|
||||
config := store.GetDefaultStrategyConfig(configLang)
|
||||
def.applyConfig(&config)
|
||||
config.ClampLimits()
|
||||
|
||||
strategy := &store.Strategy{
|
||||
ID: uuid.New().String(),
|
||||
@@ -385,11 +320,49 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
strategies = append(strategies, strategy)
|
||||
}
|
||||
|
||||
legacyDefaultNames := []string{
|
||||
"Balanced Strategy", "Steady Strategy", "Aggressive Strategy",
|
||||
"US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy",
|
||||
"Balanced Strategy", "Conservative Strategy", "Aggressive Strategy",
|
||||
"US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy",
|
||||
"Strategi Seimbang", "Strategi Konservatif", "Strategi Agresif",
|
||||
"Strategi Tren Saham AS", "Strategi Stabil Saham AS", "Strategi Breakout Saham AS",
|
||||
}
|
||||
|
||||
return s.store.Transaction(func(tx *gorm.DB) error {
|
||||
// Remove obsolete built-in risk-profile presets for this user. If a trader still
|
||||
// references one of them, keep it to avoid breaking an existing running setup.
|
||||
deleteResult := tx.Where("user_id = ? AND name IN ? AND id NOT IN (SELECT strategy_id FROM traders WHERE user_id = ? AND strategy_id IS NOT NULL)", userID, legacyDefaultNames, userID).
|
||||
Delete(&store.Strategy{})
|
||||
if deleteResult.Error != nil {
|
||||
return fmt.Errorf("failed to remove legacy default strategies: %w", deleteResult.Error)
|
||||
}
|
||||
if deleteResult.RowsAffected > 0 {
|
||||
logger.Infof(" ✓ Removed %d legacy default strategy preset(s)", deleteResult.RowsAffected)
|
||||
}
|
||||
|
||||
var activeCount int64
|
||||
if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND is_active = ?", userID, true).Count(&activeCount).Error; err != nil {
|
||||
return fmt.Errorf("failed to count active strategies: %w", err)
|
||||
}
|
||||
|
||||
for _, strategy := range strategies {
|
||||
var existing int64
|
||||
if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND name = ?", userID, strategy.Name).Count(&existing).Error; err != nil {
|
||||
return fmt.Errorf("failed to check strategy %q: %w", strategy.Name, err)
|
||||
}
|
||||
if existing > 0 {
|
||||
continue
|
||||
}
|
||||
if activeCount > 0 {
|
||||
strategy.IsActive = false
|
||||
}
|
||||
if err := tx.Create(strategy).Error; err != nil {
|
||||
return fmt.Errorf("failed to create strategy %q: %w", strategy.Name, err)
|
||||
}
|
||||
if strategy.IsActive {
|
||||
activeCount++
|
||||
}
|
||||
logger.Infof(" ✓ Created default strategy: %s (active=%v)", strategy.Name, strategy.IsActive)
|
||||
}
|
||||
return nil
|
||||
|
||||
136
api/handler_user_default_strategy_test.go
Normal file
136
api/handler_user_default_strategy_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
func TestCreateDefaultStrategiesUsesOneReadyToRunClaw402Preset(t *testing.T) {
|
||||
st, err := store.New(t.TempDir() + "/nofx.db")
|
||||
if err != nil {
|
||||
t.Fatalf("store.New failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
s := &Server{store: st}
|
||||
userID := "user-us-stock-presets"
|
||||
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
|
||||
t.Fatalf("createDefaultStrategies failed: %v", err)
|
||||
}
|
||||
|
||||
strategies, err := st.Strategy().List(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("List strategies failed: %v", err)
|
||||
}
|
||||
if len(strategies) != 1 {
|
||||
t.Fatalf("expected 1 default strategy, got %d", len(strategies))
|
||||
}
|
||||
|
||||
byName := map[string]*store.Strategy{}
|
||||
activeCount := 0
|
||||
for _, strategy := range strategies {
|
||||
byName[strategy.Name] = strategy
|
||||
if strategy.IsActive {
|
||||
activeCount++
|
||||
}
|
||||
if strategy.Name == "Balanced Strategy" || strategy.Name == "Steady Strategy" || strategy.Name == "Aggressive Strategy" {
|
||||
t.Fatalf("legacy crypto-style default strategy still present: %s", strategy.Name)
|
||||
}
|
||||
}
|
||||
if activeCount != 1 {
|
||||
t.Fatalf("expected exactly one active strategy, got %d", activeCount)
|
||||
}
|
||||
|
||||
defaultStrategy := byName["NOFX Claw402 Auto Strategy"]
|
||||
if defaultStrategy == nil || !defaultStrategy.IsActive {
|
||||
t.Fatalf("NOFX Claw402 Auto Strategy should exist and be active")
|
||||
}
|
||||
trendCfg, err := defaultStrategy.ParseConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("default ParseConfig failed: %v", err)
|
||||
}
|
||||
if trendCfg.CoinSource.SourceType != "vergex_signal" || trendCfg.CoinSource.VergexLimit != 10 || trendCfg.CoinSource.VergexMarketType != "all" {
|
||||
t.Fatalf("default strategy should use the Claw402/Vergex all-market signal ranking, got %+v", trendCfg.CoinSource)
|
||||
}
|
||||
if trendCfg.CoinSource.UseAI500 || trendCfg.RiskControl.MaxPositions != 2 {
|
||||
t.Fatalf("default strategy should be Claw402/Vergex native with a 2-position concentrated book, got coin=%+v risk=%+v", trendCfg.CoinSource, trendCfg.RiskControl)
|
||||
}
|
||||
if trendCfg.RiskControl.BTCETHMaxLeverage != 5 || trendCfg.RiskControl.AltcoinMaxLeverage != 5 {
|
||||
t.Fatalf("default strategy should use 5x leverage for all Claw402 opens, got risk=%+v", trendCfg.RiskControl)
|
||||
}
|
||||
if trendCfg.RiskControl.BTCETHMaxPositionValueRatio != 2.5 ||
|
||||
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 2.5 ||
|
||||
trendCfg.RiskControl.MaxMarginUsage != 1.0 {
|
||||
t.Fatalf("default strategy should size Claw402 opens at 2.5x equity notional (2 positions = 5x total at 5x), got risk=%+v", trendCfg.RiskControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDefaultStrategiesMigratesLegacyPresetsWithoutOverridingActiveCustom(t *testing.T) {
|
||||
st, err := store.New(t.TempDir() + "/nofx.db")
|
||||
if err != nil {
|
||||
t.Fatalf("store.New failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
userID := "user-existing-custom"
|
||||
legacyCfg := store.GetDefaultStrategyConfig("zh")
|
||||
legacy := &store.Strategy{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: "Balanced Strategy",
|
||||
Description: "legacy",
|
||||
IsActive: false,
|
||||
}
|
||||
if err := legacy.SetConfig(&legacyCfg); err != nil {
|
||||
t.Fatalf("legacy SetConfig failed: %v", err)
|
||||
}
|
||||
if err := st.Strategy().Create(legacy); err != nil {
|
||||
t.Fatalf("create legacy failed: %v", err)
|
||||
}
|
||||
|
||||
custom := &store.Strategy{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: "aa",
|
||||
Description: "user custom active strategy",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := custom.SetConfig(&legacyCfg); err != nil {
|
||||
t.Fatalf("custom SetConfig failed: %v", err)
|
||||
}
|
||||
if err := st.Strategy().Create(custom); err != nil {
|
||||
t.Fatalf("create custom failed: %v", err)
|
||||
}
|
||||
|
||||
s := &Server{store: st}
|
||||
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
|
||||
t.Fatalf("createDefaultStrategies failed: %v", err)
|
||||
}
|
||||
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
|
||||
t.Fatalf("second createDefaultStrategies should be idempotent: %v", err)
|
||||
}
|
||||
|
||||
strategies, err := st.Strategy().List(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("List strategies failed: %v", err)
|
||||
}
|
||||
byName := map[string]int{}
|
||||
activeNames := []string{}
|
||||
for _, strategy := range strategies {
|
||||
byName[strategy.Name]++
|
||||
if strategy.IsActive {
|
||||
activeNames = append(activeNames, strategy.Name)
|
||||
}
|
||||
}
|
||||
if byName["Balanced Strategy"] != 0 {
|
||||
t.Fatalf("legacy preset should be removed, got names=%+v", byName)
|
||||
}
|
||||
if byName["NOFX Claw402 Auto Strategy"] != 1 {
|
||||
t.Fatalf("expected exactly one NOFX Claw402 Auto Strategy, got names=%+v", byName)
|
||||
}
|
||||
if len(activeNames) != 1 || activeNames[0] != "aa" {
|
||||
t.Fatalf("existing active custom strategy should stay the only active one, got %+v", activeNames)
|
||||
}
|
||||
}
|
||||
137
api/handler_vergex.go
Normal file
137
api/handler_vergex.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"nofx/provider/vergex"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (s *Server) handleVergexSignalRanking(c *gin.Context) {
|
||||
client, ok := s.newVergexClientForRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := client.GetSignalRanking(context.Background(), vergex.Query{
|
||||
Chain: strings.TrimSpace(c.Query("chain")),
|
||||
LiqBand: strings.TrimSpace(c.Query("liqBand")),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warnf("Vergex signal-ranking failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
limit := parsePositiveInt(c.Query("limit"), vergex.MaxSignalRankingItems)
|
||||
marketType := strings.TrimSpace(c.Query("marketType"))
|
||||
items := vergex.FilterSignalRankingItems(data.Items, marketType, limit)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"raw": data.Raw,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleVergexSignalLab(c *gin.Context) {
|
||||
client, ok := s.newVergexClientForRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := client.GetSignalLab(context.Background(), vergex.Query{
|
||||
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
|
||||
Symbol: strings.TrimSpace(c.Query("symbol")),
|
||||
Chain: strings.TrimSpace(c.Query("chain")),
|
||||
LiqBand: strings.TrimSpace(c.Query("liqBand")),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warnf("Vergex signal-lab failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
||||
}
|
||||
|
||||
func (s *Server) handleVergexCostLiquidationHeatmap(c *gin.Context) {
|
||||
client, ok := s.newVergexClientForRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := client.GetCostLiquidationHeatmap(context.Background(), vergex.Query{
|
||||
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
|
||||
Symbol: strings.TrimSpace(c.Query("symbol")),
|
||||
Chain: strings.TrimSpace(c.Query("chain")),
|
||||
LiqBand: strings.TrimSpace(c.Query("liqBand")),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warnf("Vergex cost-liquidation-heatmap failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
||||
}
|
||||
|
||||
// handleVergexFlowMarkets proxies the Vergex net-flow market ranking (paid x402
|
||||
// endpoint) using the caller's claw402 wallet. The upstream JSON is passed
|
||||
// through verbatim: { data: { window, by, inflow: [{ symbol, netFlow,
|
||||
// buyNotional, sellNotional, trades, latestPrice }, ...] } }.
|
||||
func (s *Server) handleVergexFlowMarkets(c *gin.Context) {
|
||||
client, ok := s.newVergexClientForRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
chain := withDefault(strings.TrimSpace(c.Query("chain")), "mainnet")
|
||||
window := withDefault(strings.TrimSpace(c.Query("window")), "1h")
|
||||
limit := parsePositiveInt(c.Query("limit"), 25)
|
||||
|
||||
body, err := client.GetFlowMarkets(context.Background(), chain, window, limit)
|
||||
if err != nil {
|
||||
logger.Warnf("Vergex flow-markets failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
||||
}
|
||||
|
||||
func (s *Server) newVergexClientForRequest(c *gin.Context) (*vergex.Client, bool) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return nil, false
|
||||
}
|
||||
walletKey, err := s.resolveStrategyDataWalletKey(userID, c.Query("ai_model_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return nil, false
|
||||
}
|
||||
if walletKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "claw402 wallet is not configured"})
|
||||
return nil, false
|
||||
}
|
||||
client, err := vergex.NewClient("", walletKey, &logger.MCPLogger{})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return nil, false
|
||||
}
|
||||
return client, true
|
||||
}
|
||||
|
||||
func parsePositiveInt(raw string, fallback int) int {
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil || n <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func withDefault(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
375
api/launch_preflight.go
Normal file
375
api/launch_preflight.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/store"
|
||||
"nofx/wallet"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Launch readiness minimums. These are the single source of truth — the
|
||||
// frontend reads them from the preflight response instead of hardcoding.
|
||||
const (
|
||||
// MinAIFeeUSDC is the minimum Base USDC a claw402 fee wallet needs so the
|
||||
// trader can pay for its first AI/data calls.
|
||||
MinAIFeeUSDC = 1.0
|
||||
// MinTradingUSDC is the minimum available balance the exchange account
|
||||
// needs before the trader can place its first order.
|
||||
MinTradingUSDC = 12.0
|
||||
)
|
||||
|
||||
const (
|
||||
launchCheckStatusOK = "ok"
|
||||
launchCheckStatusFailed = "failed"
|
||||
launchCheckStatusWarning = "warning"
|
||||
launchCheckStatusSkipped = "skipped"
|
||||
)
|
||||
|
||||
// Check IDs — stable identifiers the frontend maps to guided-setup steps.
|
||||
const (
|
||||
launchCheckAIModel = "ai_model"
|
||||
launchCheckAIWallet = "ai_wallet"
|
||||
launchCheckAIWalletFunds = "ai_wallet_funds"
|
||||
launchCheckStrategy = "strategy"
|
||||
launchCheckExchangeConfig = "exchange_config"
|
||||
launchCheckExchangeAccount = "exchange_account"
|
||||
launchCheckExchangeFunds = "exchange_funds"
|
||||
)
|
||||
|
||||
// queryAIWalletBalance is swappable in tests to avoid live Base RPC calls.
|
||||
var queryAIWalletBalance = wallet.QueryUSDCBalanceCached
|
||||
|
||||
type LaunchCheck struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
// Numeric context so the UI can render progress like "8.40 / 12 USDC".
|
||||
Required float64 `json:"required,omitempty"`
|
||||
Actual *float64 `json:"actual,omitempty"`
|
||||
Asset string `json:"asset,omitempty"`
|
||||
// Address is the funding address for balance checks, so the UI can offer
|
||||
// a deposit shortcut next to the failing item.
|
||||
Address string `json:"address,omitempty"`
|
||||
}
|
||||
|
||||
type LaunchPreflightResult struct {
|
||||
Ready bool `json:"ready"`
|
||||
Checks []LaunchCheck `json:"checks"`
|
||||
MinAIFeeUSDC float64 `json:"min_ai_fee_usdc"`
|
||||
MinTradingUSDC float64 `json:"min_trading_usdc"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
|
||||
func (r LaunchPreflightResult) failedChecks() []LaunchCheck {
|
||||
var failed []LaunchCheck
|
||||
for _, check := range r.Checks {
|
||||
if check.Status == launchCheckStatusFailed {
|
||||
failed = append(failed, check)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// Summary joins the failing messages into one human-readable sentence.
|
||||
func (r LaunchPreflightResult) Summary() string {
|
||||
failed := r.failedChecks()
|
||||
if len(failed) == 0 {
|
||||
return ""
|
||||
}
|
||||
messages := make([]string, 0, len(failed))
|
||||
for _, check := range failed {
|
||||
if check.Message != "" {
|
||||
messages = append(messages, check.Message)
|
||||
}
|
||||
}
|
||||
return strings.Join(messages, " ")
|
||||
}
|
||||
|
||||
type launchPreflightRequest struct {
|
||||
AIModelID string `json:"ai_model_id"`
|
||||
ExchangeID string `json:"exchange_id"`
|
||||
StrategyID string `json:"strategy_id"`
|
||||
}
|
||||
|
||||
// handleLaunchPreflight runs launch readiness checks for a model/exchange
|
||||
// pair before any trader is created or mutated. POST /api/launch/preflight
|
||||
func (s *Server) handleLaunchPreflight(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
var req launchPreflightRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid preflight request")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.AIModelID) == "" || strings.TrimSpace(req.ExchangeID) == "" {
|
||||
SafeBadRequest(c, "ai_model_id and exchange_id are required")
|
||||
return
|
||||
}
|
||||
|
||||
model, err := s.store.AIModel().Get(userID, req.AIModelID)
|
||||
if err != nil {
|
||||
model = nil
|
||||
}
|
||||
exchange, err := s.store.Exchange().GetByID(userID, req.ExchangeID)
|
||||
if err != nil {
|
||||
exchange = nil
|
||||
}
|
||||
|
||||
var strategy *store.Strategy
|
||||
strategyRequired := strings.TrimSpace(req.StrategyID) != ""
|
||||
if strategyRequired {
|
||||
strategy, err = s.store.Strategy().Get(userID, req.StrategyID)
|
||||
if err != nil {
|
||||
strategy = nil
|
||||
}
|
||||
}
|
||||
|
||||
result := s.runLaunchPreflight(userID, model, exchange, strategy, strategyRequired)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleTraderPreflight runs the same checks against an existing trader's
|
||||
// full configuration. GET /api/traders/:id/preflight
|
||||
func (s *Server) handleTraderPreflight(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
traderID := c.Param("id")
|
||||
|
||||
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
|
||||
if err != nil || fullCfg == nil || fullCfg.Trader == nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
result := s.runLaunchPreflight(userID, fullCfg.AIModel, fullCfg.Exchange, fullCfg.Strategy, true)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// runLaunchPreflight composes every launch readiness check. A check that
|
||||
// cannot be evaluated (RPC outage, probe timeout) reports "warning" instead
|
||||
// of "failed" so an infrastructure hiccup never hard-blocks a launch.
|
||||
func (s *Server) runLaunchPreflight(
|
||||
userID string,
|
||||
model *store.AIModel,
|
||||
exchange *store.Exchange,
|
||||
strategy *store.Strategy,
|
||||
strategyRequired bool,
|
||||
) LaunchPreflightResult {
|
||||
checks := []LaunchCheck{checkLaunchAIModel(model)}
|
||||
checks = append(checks, checkLaunchAIWallet(model)...)
|
||||
checks = append(checks, checkLaunchStrategy(strategy, strategyRequired))
|
||||
checks = append(checks, s.checkLaunchExchange(userID, exchange)...)
|
||||
|
||||
ready := true
|
||||
for _, check := range checks {
|
||||
if check.Status == launchCheckStatusFailed {
|
||||
ready = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return LaunchPreflightResult{
|
||||
Ready: ready,
|
||||
Checks: checks,
|
||||
MinAIFeeUSDC: MinAIFeeUSDC,
|
||||
MinTradingUSDC: MinTradingUSDC,
|
||||
CheckedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func checkLaunchAIModel(model *store.AIModel) LaunchCheck {
|
||||
check := LaunchCheck{ID: launchCheckAIModel}
|
||||
|
||||
switch {
|
||||
case model == nil:
|
||||
check.Status = launchCheckStatusFailed
|
||||
check.Code = "MODEL_NOT_FOUND"
|
||||
check.Message = "The selected AI model was not found. Configure an AI model first."
|
||||
case !model.Enabled:
|
||||
check.Status = launchCheckStatusFailed
|
||||
check.Code = "MODEL_DISABLED"
|
||||
check.Message = fmt.Sprintf("AI model \"%s\" is disabled. Enable it first.", model.Name)
|
||||
case strings.TrimSpace(model.APIKey.String()) == "":
|
||||
check.Status = launchCheckStatusFailed
|
||||
check.Code = "MODEL_MISSING_CREDENTIALS"
|
||||
check.Message = fmt.Sprintf("AI model \"%s\" has no credential saved. Add the API key or wallet key first.", model.Name)
|
||||
default:
|
||||
check.Status = launchCheckStatusOK
|
||||
check.Message = model.Name
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// checkLaunchAIWallet validates the claw402 fee wallet (address + Base USDC
|
||||
// balance). Non-claw402 providers pay per API key, so both checks are skipped.
|
||||
func checkLaunchAIWallet(model *store.AIModel) []LaunchCheck {
|
||||
walletCheck := LaunchCheck{ID: launchCheckAIWallet}
|
||||
fundsCheck := LaunchCheck{ID: launchCheckAIWalletFunds, Asset: "USDC", Required: MinAIFeeUSDC}
|
||||
|
||||
if model == nil || model.Provider != "claw402" || strings.TrimSpace(model.APIKey.String()) == "" {
|
||||
walletCheck.Status = launchCheckStatusSkipped
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{walletCheck, fundsCheck}
|
||||
}
|
||||
|
||||
address, err := walletAddressFromPrivateKey(model.APIKey.String())
|
||||
if err != nil {
|
||||
walletCheck.Status = launchCheckStatusFailed
|
||||
walletCheck.Code = "AI_WALLET_INVALID_KEY"
|
||||
walletCheck.Message = "The Claw402 wallet key is invalid. Recreate the Base USDC payment wallet."
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{walletCheck, fundsCheck}
|
||||
}
|
||||
|
||||
walletCheck.Status = launchCheckStatusOK
|
||||
walletCheck.Address = address
|
||||
fundsCheck.Address = address
|
||||
|
||||
balance, err := queryAIWalletBalance(address)
|
||||
if err != nil {
|
||||
fundsCheck.Status = launchCheckStatusWarning
|
||||
fundsCheck.Code = "AI_WALLET_BALANCE_UNKNOWN"
|
||||
fundsCheck.Message = "Could not verify the Base USDC balance right now. The trader will start, but AI calls fail if the wallet is empty."
|
||||
return []LaunchCheck{walletCheck, fundsCheck}
|
||||
}
|
||||
|
||||
fundsCheck.Actual = &balance
|
||||
if balance < MinAIFeeUSDC {
|
||||
fundsCheck.Status = launchCheckStatusFailed
|
||||
fundsCheck.Code = "AI_WALLET_INSUFFICIENT_FUNDS"
|
||||
fundsCheck.Message = fmt.Sprintf(
|
||||
"The Claw402 wallet holds %.2f USDC but needs at least %.0f USDC on Base to pay for AI and data calls.",
|
||||
balance, MinAIFeeUSDC,
|
||||
)
|
||||
} else {
|
||||
fundsCheck.Status = launchCheckStatusOK
|
||||
}
|
||||
|
||||
return []LaunchCheck{walletCheck, fundsCheck}
|
||||
}
|
||||
|
||||
func checkLaunchStrategy(strategy *store.Strategy, required bool) LaunchCheck {
|
||||
check := LaunchCheck{ID: launchCheckStrategy}
|
||||
|
||||
switch {
|
||||
case strategy != nil:
|
||||
check.Status = launchCheckStatusOK
|
||||
check.Message = strategy.Name
|
||||
case required:
|
||||
check.Status = launchCheckStatusFailed
|
||||
check.Code = "STRATEGY_NOT_FOUND"
|
||||
check.Message = "The selected strategy was not found. Pick or create a strategy first."
|
||||
default:
|
||||
check.Status = launchCheckStatusSkipped
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// checkLaunchExchange validates exchange configuration completeness and then
|
||||
// probes the live account (30s server cache) for status and balance.
|
||||
func (s *Server) checkLaunchExchange(userID string, exchange *store.Exchange) []LaunchCheck {
|
||||
configCheck := LaunchCheck{ID: launchCheckExchangeConfig}
|
||||
accountCheck := LaunchCheck{ID: launchCheckExchangeAccount}
|
||||
fundsCheck := LaunchCheck{ID: launchCheckExchangeFunds, Required: MinTradingUSDC}
|
||||
|
||||
if msg, code := describeExchangeConfigIssue(exchange); code != "" {
|
||||
configCheck.Status = launchCheckStatusFailed
|
||||
configCheck.Code = code
|
||||
configCheck.Message = msg
|
||||
accountCheck.Status = launchCheckStatusSkipped
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
configCheck.Status = launchCheckStatusOK
|
||||
configCheck.Message = exchangeDisplayName(exchange)
|
||||
fundsCheck.Asset = accountAssetForExchange(exchange.ExchangeType)
|
||||
|
||||
states, err := s.getExchangeAccountStates(userID)
|
||||
if err != nil {
|
||||
accountCheck.Status = launchCheckStatusWarning
|
||||
accountCheck.Code = "EXCHANGE_STATE_UNKNOWN"
|
||||
accountCheck.Message = "Could not verify the exchange account right now."
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
state, ok := states[exchange.ID]
|
||||
if !ok {
|
||||
accountCheck.Status = launchCheckStatusWarning
|
||||
accountCheck.Code = "EXCHANGE_STATE_UNKNOWN"
|
||||
accountCheck.Message = "Could not verify the exchange account right now."
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
if state.Status != exchangeAccountStatusOK {
|
||||
accountCheck.Status = launchCheckStatusFailed
|
||||
accountCheck.Code = state.ErrorCode
|
||||
accountCheck.Message = state.ErrorMessage
|
||||
if accountCheck.Message == "" {
|
||||
accountCheck.Message = fmt.Sprintf("Exchange account \"%s\" is not ready (%s).", exchangeDisplayName(exchange), state.Status)
|
||||
}
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
accountCheck.Status = launchCheckStatusOK
|
||||
accountCheck.Message = state.DisplayBalance
|
||||
|
||||
// Gate on the better of available balance and total equity: a bot with
|
||||
// capital deployed in open positions has low *available* margin but is
|
||||
// clearly funded — restarting it must not be blocked.
|
||||
funded := state.AvailableBalance
|
||||
if state.TotalEquity > funded {
|
||||
funded = state.TotalEquity
|
||||
}
|
||||
fundsCheck.Actual = &funded
|
||||
|
||||
if funded < MinTradingUSDC {
|
||||
message := fmt.Sprintf(
|
||||
"Exchange account \"%s\" holds %.2f %s but needs at least %.0f %s to place the first trade.",
|
||||
exchangeDisplayName(exchange), funded, fundsCheck.Asset, MinTradingUSDC, fundsCheck.Asset,
|
||||
)
|
||||
if exchange.Testnet {
|
||||
// Testnet balances are play money — warn instead of block.
|
||||
fundsCheck.Status = launchCheckStatusWarning
|
||||
} else {
|
||||
fundsCheck.Status = launchCheckStatusFailed
|
||||
}
|
||||
fundsCheck.Code = "EXCHANGE_INSUFFICIENT_FUNDS"
|
||||
fundsCheck.Message = message
|
||||
} else {
|
||||
fundsCheck.Status = launchCheckStatusOK
|
||||
}
|
||||
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
// describeExchangeConfigIssue mirrors the create-time exchange validation but
|
||||
// returns stable uppercase codes for the checklist UI.
|
||||
func describeExchangeConfigIssue(exchange *store.Exchange) (string, string) {
|
||||
if exchange == nil {
|
||||
return "The selected exchange account was not found. Connect an exchange first.", "EXCHANGE_NOT_FOUND"
|
||||
}
|
||||
if !exchange.Enabled {
|
||||
return fmt.Sprintf("Exchange account \"%s\" is disabled. Enable it first.", exchangeDisplayName(exchange)), "EXCHANGE_DISABLED"
|
||||
}
|
||||
if missing := missingExchangeFields(exchange); len(missing) > 0 {
|
||||
return fmt.Sprintf(
|
||||
"Exchange account \"%s\" is missing %s. Complete the connection first.",
|
||||
exchangeDisplayName(exchange), strings.Join(missing, ", "),
|
||||
), "EXCHANGE_MISSING_FIELDS"
|
||||
}
|
||||
if exchange.ExchangeType == "hyperliquid" && !exchange.HyperliquidBuilderApproved {
|
||||
return "Hyperliquid builder authorization is not complete. Reconnect the wallet and finish the authorization.", "HYPERLIQUID_BUILDER_NOT_APPROVED"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
293
api/launch_preflight_test.go
Normal file
293
api/launch_preflight_test.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"nofx/crypto"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
// Well-known throwaway development key (hardhat account #1) — never funded.
|
||||
const testClaw402Key = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"
|
||||
|
||||
func withAIWalletBalance(t *testing.T, balance float64, err error) {
|
||||
t.Helper()
|
||||
original := queryAIWalletBalance
|
||||
queryAIWalletBalance = func(string) (float64, error) {
|
||||
return balance, err
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
queryAIWalletBalance = original
|
||||
})
|
||||
}
|
||||
|
||||
func findCheck(t *testing.T, checks []LaunchCheck, id string) LaunchCheck {
|
||||
t.Helper()
|
||||
for _, check := range checks {
|
||||
if check.ID == id {
|
||||
return check
|
||||
}
|
||||
}
|
||||
t.Fatalf("check %q not found in %+v", id, checks)
|
||||
return LaunchCheck{}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIModel(t *testing.T) {
|
||||
if got := checkLaunchAIModel(nil); got.Status != launchCheckStatusFailed || got.Code != "MODEL_NOT_FOUND" {
|
||||
t.Fatalf("nil model: expected failed/MODEL_NOT_FOUND, got %+v", got)
|
||||
}
|
||||
|
||||
disabled := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: false}
|
||||
if got := checkLaunchAIModel(disabled); got.Code != "MODEL_DISABLED" {
|
||||
t.Fatalf("disabled model: expected MODEL_DISABLED, got %+v", got)
|
||||
}
|
||||
|
||||
noKey := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true}
|
||||
if got := checkLaunchAIModel(noKey); got.Code != "MODEL_MISSING_CREDENTIALS" {
|
||||
t.Fatalf("missing key: expected MODEL_MISSING_CREDENTIALS, got %+v", got)
|
||||
}
|
||||
|
||||
ready := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
if got := checkLaunchAIModel(ready); got.Status != launchCheckStatusOK {
|
||||
t.Fatalf("ready model: expected ok, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIWalletSkipsNonClaw402(t *testing.T) {
|
||||
model := &store.AIModel{Name: "DeepSeek", Provider: "deepseek", Enabled: true, APIKey: crypto.EncryptedString("sk-test")}
|
||||
checks := checkLaunchAIWallet(model)
|
||||
if got := findCheck(t, checks, launchCheckAIWallet); got.Status != launchCheckStatusSkipped {
|
||||
t.Fatalf("non-claw402 wallet check should be skipped, got %+v", got)
|
||||
}
|
||||
if got := findCheck(t, checks, launchCheckAIWalletFunds); got.Status != launchCheckStatusSkipped {
|
||||
t.Fatalf("non-claw402 funds check should be skipped, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIWalletInvalidKey(t *testing.T) {
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString("not-a-key")}
|
||||
checks := checkLaunchAIWallet(model)
|
||||
got := findCheck(t, checks, launchCheckAIWallet)
|
||||
if got.Status != launchCheckStatusFailed || got.Code != "AI_WALLET_INVALID_KEY" {
|
||||
t.Fatalf("invalid key: expected failed/AI_WALLET_INVALID_KEY, got %+v", got)
|
||||
}
|
||||
if funds := findCheck(t, checks, launchCheckAIWalletFunds); funds.Status != launchCheckStatusSkipped {
|
||||
t.Fatalf("funds check should be skipped when the key is invalid, got %+v", funds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIWalletInsufficientFunds(t *testing.T) {
|
||||
withAIWalletBalance(t, 0.25, nil)
|
||||
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
checks := checkLaunchAIWallet(model)
|
||||
|
||||
wallet := findCheck(t, checks, launchCheckAIWallet)
|
||||
if wallet.Status != launchCheckStatusOK || wallet.Address == "" {
|
||||
t.Fatalf("wallet check should pass with derived address, got %+v", wallet)
|
||||
}
|
||||
|
||||
funds := findCheck(t, checks, launchCheckAIWalletFunds)
|
||||
if funds.Status != launchCheckStatusFailed || funds.Code != "AI_WALLET_INSUFFICIENT_FUNDS" {
|
||||
t.Fatalf("expected failed/AI_WALLET_INSUFFICIENT_FUNDS, got %+v", funds)
|
||||
}
|
||||
if funds.Actual == nil || *funds.Actual != 0.25 {
|
||||
t.Fatalf("expected actual balance 0.25, got %+v", funds.Actual)
|
||||
}
|
||||
if funds.Required != MinAIFeeUSDC {
|
||||
t.Fatalf("expected required %v, got %v", MinAIFeeUSDC, funds.Required)
|
||||
}
|
||||
if funds.Address == "" {
|
||||
t.Fatalf("funds check should carry the deposit address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIWalletRPCOutageIsWarningNotFailure(t *testing.T) {
|
||||
withAIWalletBalance(t, 0, errors.New("rpc unreachable"))
|
||||
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
checks := checkLaunchAIWallet(model)
|
||||
|
||||
funds := findCheck(t, checks, launchCheckAIWalletFunds)
|
||||
if funds.Status != launchCheckStatusWarning || funds.Code != "AI_WALLET_BALANCE_UNKNOWN" {
|
||||
t.Fatalf("RPC outage must degrade to warning, got %+v", funds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIWalletFunded(t *testing.T) {
|
||||
withAIWalletBalance(t, 25.5, nil)
|
||||
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
checks := checkLaunchAIWallet(model)
|
||||
|
||||
funds := findCheck(t, checks, launchCheckAIWalletFunds)
|
||||
if funds.Status != launchCheckStatusOK {
|
||||
t.Fatalf("funded wallet should pass, got %+v", funds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeExchangeConfigIssue(t *testing.T) {
|
||||
if _, code := describeExchangeConfigIssue(nil); code != "EXCHANGE_NOT_FOUND" {
|
||||
t.Fatalf("nil exchange: expected EXCHANGE_NOT_FOUND, got %s", code)
|
||||
}
|
||||
|
||||
disabled := &store.Exchange{ID: "ex", ExchangeType: "hyperliquid", Enabled: false}
|
||||
if _, code := describeExchangeConfigIssue(disabled); code != "EXCHANGE_DISABLED" {
|
||||
t.Fatalf("disabled: expected EXCHANGE_DISABLED, got %s", code)
|
||||
}
|
||||
|
||||
missing := &store.Exchange{ID: "ex", ExchangeType: "hyperliquid", Enabled: true}
|
||||
if _, code := describeExchangeConfigIssue(missing); code != "EXCHANGE_MISSING_FIELDS" {
|
||||
t.Fatalf("missing fields: expected EXCHANGE_MISSING_FIELDS, got %s", code)
|
||||
}
|
||||
|
||||
unapproved := &store.Exchange{
|
||||
ID: "ex",
|
||||
ExchangeType: "hyperliquid",
|
||||
Enabled: true,
|
||||
APIKey: crypto.EncryptedString(testClaw402Key),
|
||||
HyperliquidWalletAddr: "0x1111111111111111111111111111111111111111",
|
||||
}
|
||||
if _, code := describeExchangeConfigIssue(unapproved); code != "HYPERLIQUID_BUILDER_NOT_APPROVED" {
|
||||
t.Fatalf("builder unapproved: expected HYPERLIQUID_BUILDER_NOT_APPROVED, got %s", code)
|
||||
}
|
||||
|
||||
unapproved.HyperliquidBuilderApproved = true
|
||||
if _, code := describeExchangeConfigIssue(unapproved); code != "" {
|
||||
t.Fatalf("ready exchange: expected no issue, got %s", code)
|
||||
}
|
||||
}
|
||||
|
||||
func readyHyperliquidExchange() *store.Exchange {
|
||||
return &store.Exchange{
|
||||
ID: "ex-hl",
|
||||
ExchangeType: "hyperliquid",
|
||||
Enabled: true,
|
||||
APIKey: crypto.EncryptedString(testClaw402Key),
|
||||
HyperliquidWalletAddr: "0x1111111111111111111111111111111111111111",
|
||||
HyperliquidBuilderApproved: true,
|
||||
}
|
||||
}
|
||||
|
||||
func preflightTestServer(t *testing.T, userID string, states map[string]ExchangeAccountState) *Server {
|
||||
t.Helper()
|
||||
server := &Server{exchangeAccountStateCache: NewExchangeAccountStateCache()}
|
||||
if states != nil {
|
||||
server.exchangeAccountStateCache.Set(userID, states)
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
func TestCheckLaunchExchangeInsufficientFundsBlocks(t *testing.T) {
|
||||
exchange := readyHyperliquidExchange()
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 5.5, TotalEquity: 5.5},
|
||||
})
|
||||
|
||||
checks := server.checkLaunchExchange("user-1", exchange)
|
||||
funds := findCheck(t, checks, launchCheckExchangeFunds)
|
||||
if funds.Status != launchCheckStatusFailed || funds.Code != "EXCHANGE_INSUFFICIENT_FUNDS" {
|
||||
t.Fatalf("expected failed/EXCHANGE_INSUFFICIENT_FUNDS, got %+v", funds)
|
||||
}
|
||||
if funds.Actual == nil || *funds.Actual != 5.5 {
|
||||
t.Fatalf("expected actual 5.5, got %+v", funds.Actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchExchangeDeployedMarginPasses(t *testing.T) {
|
||||
// A running bot with capital locked in positions: low available balance
|
||||
// but healthy equity. Restart must not be blocked.
|
||||
exchange := readyHyperliquidExchange()
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 3, TotalEquity: 100},
|
||||
})
|
||||
|
||||
checks := server.checkLaunchExchange("user-1", exchange)
|
||||
funds := findCheck(t, checks, launchCheckExchangeFunds)
|
||||
if funds.Status != launchCheckStatusOK {
|
||||
t.Fatalf("deployed margin with healthy equity should pass, got %+v", funds)
|
||||
}
|
||||
if funds.Actual == nil || *funds.Actual != 100 {
|
||||
t.Fatalf("expected actual 100 (equity), got %+v", funds.Actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchExchangeTestnetLowFundsIsWarning(t *testing.T) {
|
||||
exchange := readyHyperliquidExchange()
|
||||
exchange.Testnet = true
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 0},
|
||||
})
|
||||
|
||||
checks := server.checkLaunchExchange("user-1", exchange)
|
||||
funds := findCheck(t, checks, launchCheckExchangeFunds)
|
||||
if funds.Status != launchCheckStatusWarning {
|
||||
t.Fatalf("testnet low funds should warn, not block, got %+v", funds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchExchangeInvalidCredentials(t *testing.T) {
|
||||
exchange := readyHyperliquidExchange()
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {
|
||||
ExchangeID: exchange.ID,
|
||||
Status: exchangeAccountStatusInvalidCredentials,
|
||||
ErrorCode: "INVALID_CREDENTIALS",
|
||||
ErrorMessage: "Exchange credentials are invalid",
|
||||
},
|
||||
})
|
||||
|
||||
checks := server.checkLaunchExchange("user-1", exchange)
|
||||
account := findCheck(t, checks, launchCheckExchangeAccount)
|
||||
if account.Status != launchCheckStatusFailed || account.Code != "INVALID_CREDENTIALS" {
|
||||
t.Fatalf("expected failed/INVALID_CREDENTIALS, got %+v", account)
|
||||
}
|
||||
if funds := findCheck(t, checks, launchCheckExchangeFunds); funds.Status != launchCheckStatusSkipped {
|
||||
t.Fatalf("funds check should be skipped when account probe fails, got %+v", funds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLaunchPreflightAggregatesReadiness(t *testing.T) {
|
||||
withAIWalletBalance(t, 10, nil)
|
||||
|
||||
exchange := readyHyperliquidExchange()
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 100, TotalEquity: 100},
|
||||
})
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
strategy := &store.Strategy{ID: "strat-1", Name: "Autopilot"}
|
||||
|
||||
result := server.runLaunchPreflight("user-1", model, exchange, strategy, true)
|
||||
if !result.Ready {
|
||||
t.Fatalf("expected ready, got %+v", result)
|
||||
}
|
||||
if result.MinAIFeeUSDC != MinAIFeeUSDC || result.MinTradingUSDC != MinTradingUSDC {
|
||||
t.Fatalf("minimums must be exposed in the response, got %+v", result)
|
||||
}
|
||||
|
||||
// Break one prerequisite → not ready, and Summary explains it.
|
||||
withAIWalletBalance(t, 0, nil)
|
||||
result = server.runLaunchPreflight("user-1", model, exchange, strategy, true)
|
||||
if result.Ready {
|
||||
t.Fatalf("expected not ready with empty AI wallet, got %+v", result)
|
||||
}
|
||||
if result.Summary() == "" {
|
||||
t.Fatalf("summary should describe the failing check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLaunchPreflightWarningsDoNotBlock(t *testing.T) {
|
||||
withAIWalletBalance(t, 0, errors.New("rpc down"))
|
||||
|
||||
exchange := readyHyperliquidExchange()
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 100},
|
||||
})
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
|
||||
result := server.runLaunchPreflight("user-1", model, exchange, nil, false)
|
||||
if !result.Ready {
|
||||
t.Fatalf("warnings must not block launch, got %+v", result)
|
||||
}
|
||||
}
|
||||
101
api/ratelimit.go
Normal file
101
api/ratelimit.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ipRateLimiter is a small, dependency-free token-bucket rate limiter keyed by
|
||||
// client IP. It is used to throttle the unauthenticated auth endpoints
|
||||
// (login / register) against online brute-force attacks.
|
||||
//
|
||||
// Design notes:
|
||||
// - Per-IP token bucket with lazy refill (no background goroutine).
|
||||
// - Idle buckets are evicted opportunistically so a flood of distinct source
|
||||
// IPs (e.g. spoofed X-Forwarded-For) cannot grow the map without bound.
|
||||
// - This is a throttle, not an authenticator. Behind a reverse proxy the
|
||||
// effective key is whatever gin's ClientIP() resolves; operators who
|
||||
// terminate TLS at a proxy should configure trusted proxies so ClientIP()
|
||||
// reflects the real peer rather than a spoofable header.
|
||||
type ipRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*rlBucket
|
||||
rate float64 // tokens added per second
|
||||
burst float64 // maximum tokens (and initial fill)
|
||||
lastGC time.Time
|
||||
}
|
||||
|
||||
type rlBucket struct {
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// newIPRateLimiter creates a limiter that allows bursts up to `burst` requests
|
||||
// and then refills at `ratePerSec` tokens/second per client IP.
|
||||
func newIPRateLimiter(ratePerSec, burst float64) *ipRateLimiter {
|
||||
return &ipRateLimiter{
|
||||
buckets: make(map[string]*rlBucket),
|
||||
rate: ratePerSec,
|
||||
burst: burst,
|
||||
}
|
||||
}
|
||||
|
||||
// allow reports whether a request from key is permitted at time now, consuming
|
||||
// one token when it is.
|
||||
func (l *ipRateLimiter) allow(key string, now time.Time) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
// Opportunistic GC: drop buckets idle for >10 minutes. Bounds memory even
|
||||
// under a spoofed-IP flood without needing a background goroutine.
|
||||
if l.lastGC.IsZero() {
|
||||
l.lastGC = now
|
||||
}
|
||||
if now.Sub(l.lastGC) > time.Minute {
|
||||
for k, b := range l.buckets {
|
||||
if now.Sub(b.last) > 10*time.Minute {
|
||||
delete(l.buckets, k)
|
||||
}
|
||||
}
|
||||
l.lastGC = now
|
||||
}
|
||||
|
||||
b, ok := l.buckets[key]
|
||||
if !ok {
|
||||
b = &rlBucket{tokens: l.burst, last: now}
|
||||
l.buckets[key] = b
|
||||
}
|
||||
|
||||
// Refill based on elapsed time, capped at burst.
|
||||
elapsed := now.Sub(b.last).Seconds()
|
||||
if elapsed > 0 {
|
||||
b.tokens = math.Min(l.burst, b.tokens+elapsed*l.rate)
|
||||
b.last = now
|
||||
}
|
||||
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
// rateLimitMiddleware throttles requests per client IP, returning 429 when the
|
||||
// caller exceeds the configured rate.
|
||||
func rateLimitMiddleware(l *ipRateLimiter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !l.allow(c.ClientIP(), time.Now()) {
|
||||
c.Header("Retry-After", "60")
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "Too many requests. Please slow down and try again in a minute.",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
54
api/ratelimit_test.go
Normal file
54
api/ratelimit_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIPRateLimiterBurstThenThrottle verifies that a client gets `burst`
|
||||
// immediate attempts and is then throttled until tokens refill.
|
||||
func TestIPRateLimiterBurstThenThrottle(t *testing.T) {
|
||||
// 1 token/sec, burst of 3.
|
||||
l := newIPRateLimiter(1.0, 3)
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
|
||||
// First 3 requests in the same instant are allowed (the burst).
|
||||
for i := 0; i < 3; i++ {
|
||||
if !l.allow("1.2.3.4", now) {
|
||||
t.Fatalf("request %d in burst should be allowed", i+1)
|
||||
}
|
||||
}
|
||||
// 4th in the same instant is throttled.
|
||||
if l.allow("1.2.3.4", now) {
|
||||
t.Fatalf("request beyond burst should be throttled")
|
||||
}
|
||||
|
||||
// After 1 second, one token refills → exactly one more request allowed.
|
||||
now = now.Add(time.Second)
|
||||
if !l.allow("1.2.3.4", now) {
|
||||
t.Fatalf("one token should have refilled after 1s")
|
||||
}
|
||||
if l.allow("1.2.3.4", now) {
|
||||
t.Fatalf("only one token should refill per second")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIPRateLimiterIsolatesClients verifies one IP exhausting its bucket does
|
||||
// not throttle a different IP.
|
||||
func TestIPRateLimiterIsolatesClients(t *testing.T) {
|
||||
l := newIPRateLimiter(1.0, 2)
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
|
||||
// Exhaust IP A.
|
||||
if !l.allow("10.0.0.1", now) || !l.allow("10.0.0.1", now) {
|
||||
t.Fatalf("IP A burst should be allowed")
|
||||
}
|
||||
if l.allow("10.0.0.1", now) {
|
||||
t.Fatalf("IP A should be throttled after burst")
|
||||
}
|
||||
|
||||
// IP B is unaffected.
|
||||
if !l.allow("10.0.0.2", now) {
|
||||
t.Fatalf("IP B should be allowed regardless of IP A")
|
||||
}
|
||||
}
|
||||
169
api/server.go
169
api/server.go
@@ -10,6 +10,7 @@ import (
|
||||
"nofx/logger"
|
||||
"nofx/manager"
|
||||
"nofx/store"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -26,6 +27,7 @@ type Server struct {
|
||||
httpServer *http.Server
|
||||
port int
|
||||
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
|
||||
authLimiter *ipRateLimiter // per-IP throttle for login/register
|
||||
}
|
||||
|
||||
// NewServer Creates API server
|
||||
@@ -48,6 +50,10 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
|
||||
cryptoHandler: cryptoHandler,
|
||||
exchangeAccountStateCache: NewExchangeAccountStateCache(),
|
||||
port: port,
|
||||
// Auth throttle: allow a small burst (typos / page reloads) then ~1
|
||||
// attempt every 6s (10/min) sustained per IP. Generous for a human,
|
||||
// hostile to online password brute-force.
|
||||
authLimiter: newIPRateLimiter(1.0/6.0, 8),
|
||||
}
|
||||
|
||||
// Setup routes
|
||||
@@ -56,24 +62,74 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
|
||||
return s
|
||||
}
|
||||
|
||||
// corsMiddleware CORS middleware
|
||||
// corsMiddleware returns a CORS handler. Origins come from CORS_ALLOWED_ORIGINS
|
||||
// (comma-separated). The literal value "*" enables permissive mode — DO NOT use
|
||||
// in production: the JWT is sent via Authorization header so a wildcard ACAO
|
||||
// makes stolen tokens replayable from any site.
|
||||
func corsMiddleware() gin.HandlerFunc {
|
||||
raw := strings.TrimSpace(os.Getenv("CORS_ALLOWED_ORIGINS"))
|
||||
allowAny := raw == "*"
|
||||
var allowlist map[string]struct{}
|
||||
if !allowAny {
|
||||
allowlist = make(map[string]struct{})
|
||||
for _, o := range strings.Split(raw, ",") {
|
||||
o = strings.TrimSpace(o)
|
||||
if o == "" {
|
||||
continue
|
||||
}
|
||||
allowlist[o] = struct{}{}
|
||||
}
|
||||
if len(allowlist) == 0 {
|
||||
// Safe defaults for local development.
|
||||
for _, o := range []string{
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
} {
|
||||
allowlist[o] = struct{}{}
|
||||
}
|
||||
logger.Warnf("[CORS] CORS_ALLOWED_ORIGINS not set; defaulting to localhost dev origins only. Set this env var for production.")
|
||||
}
|
||||
if allowAny {
|
||||
logger.Warnf("[CORS] CORS_ALLOWED_ORIGINS=* is INSECURE in production; restrict to your deployment origin(s).")
|
||||
}
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" {
|
||||
switch {
|
||||
case allowAny:
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
c.Writer.Header().Set("Vary", "Origin")
|
||||
default:
|
||||
if _, ok := allowlist[origin]; ok {
|
||||
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
c.Writer.Header().Set("Vary", "Origin")
|
||||
}
|
||||
// Unknown origin: do not set ACAO; the browser will block.
|
||||
}
|
||||
}
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
c.Writer.Header().Set("Access-Control-Max-Age", "600")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// setupRoutes Setup routes
|
||||
func (s *Server) setupRoutes() {
|
||||
// Ensure the auth throttle exists even when the Server was constructed
|
||||
// directly (e.g. in tests) rather than via NewServer.
|
||||
if s.authLimiter == nil {
|
||||
s.authLimiter = newIPRateLimiter(1.0/6.0, 8)
|
||||
}
|
||||
|
||||
// API route group
|
||||
api := s.router.Group("/api")
|
||||
{
|
||||
@@ -92,11 +148,21 @@ func (s *Server) setupRoutes() {
|
||||
// Wallet validation (no authentication required — used by frontend config form)
|
||||
api.POST("/wallet/validate", s.handleWalletValidate)
|
||||
api.POST("/wallet/generate", s.handleWalletGenerate)
|
||||
s.route(api, "GET", "/hyperliquid/connect-config", "Get NOFX Hyperliquid builder authorization config", s.handleHyperliquidConnectConfig)
|
||||
s.route(api, "GET", "/hyperliquid/account", "Get Hyperliquid account balance summary", s.handleHyperliquidAccount)
|
||||
s.route(api, "GET", "/hyperliquid/agent", "Get Hyperliquid approved agent wallets and authorization expiry", s.handleHyperliquidAgent)
|
||||
s.route(api, "POST", "/hyperliquid/submit-exchange", "Submit a user-signed Hyperliquid approval action", s.handleHyperliquidSubmitExchange)
|
||||
|
||||
// Crypto related endpoints (no authentication required, not exposed to bot)
|
||||
// Crypto related endpoints (no authentication required, not exposed to bot).
|
||||
// SECURITY: only the config + public-key endpoints are exposed. Transport
|
||||
// encryption is one-directional (client encrypts to the server's public key;
|
||||
// the server decrypts internally on the authenticated config-update handlers).
|
||||
// A public POST /crypto/decrypt would be a decryption oracle: any
|
||||
// unauthenticated caller could replay a captured ciphertext and get the
|
||||
// plaintext (exchange/API credentials) back. It is intentionally NOT
|
||||
// registered. See crypto_handler.go.
|
||||
api.GET("/crypto/config", s.cryptoHandler.HandleGetCryptoConfig)
|
||||
api.GET("/crypto/public-key", s.cryptoHandler.HandleGetPublicKey)
|
||||
api.POST("/crypto/decrypt", s.cryptoHandler.HandleDecryptSensitiveData)
|
||||
|
||||
// Public competition data (no authentication required)
|
||||
s.route(api, "GET", "/traders", "Public trader list", s.handlePublicTraderList)
|
||||
@@ -114,11 +180,20 @@ func (s *Server) setupRoutes() {
|
||||
s.route(api, "GET", "/strategies/public", "Public strategy market", s.handlePublicStrategies)
|
||||
s.route(api, "POST", "/strategies/estimate-tokens", "Estimate token usage for a strategy config", s.handleEstimateTokens)
|
||||
|
||||
// Authentication related routes (no authentication required)
|
||||
s.route(api, "POST", "/register", "Register new user", s.handleRegister)
|
||||
s.route(api, "POST", "/login", "User login, returns JWT token", s.handleLogin)
|
||||
s.route(api, "POST", "/reset-password", "Reset password", s.handleResetPassword)
|
||||
s.route(api, "POST", "/reset-account", "Clear all users and reset system to allow re-registration", s.handleResetAccount)
|
||||
// Authentication related routes (no authentication required).
|
||||
// These are throttled per-IP to blunt online password brute-force; see
|
||||
// ratelimit.go. Everything else in the public block is read-only or
|
||||
// idempotent, so the throttle is scoped to the credential endpoints.
|
||||
authRoutes := api.Group("/", rateLimitMiddleware(s.authLimiter))
|
||||
s.route(authRoutes, "POST", "/register", "Register new user", s.handleRegister)
|
||||
s.route(authRoutes, "POST", "/login", "User login, returns JWT token", s.handleLogin)
|
||||
// SECURITY: password/account recovery is NOT exposed over HTTP. An
|
||||
// unauthenticated recovery endpoint is a remote auth-bypass on any
|
||||
// public-facing deployment (the confirm phrase is in the frontend and
|
||||
// returned by the API, so it is friction, not authentication). Recovery
|
||||
// is now a local CLI run on the host — `nofx reset-password` /
|
||||
// `nofx reset-account` — which requires shell access the attacker lacks.
|
||||
// See cli.go.
|
||||
|
||||
// Routes requiring authentication
|
||||
protected := api.Group("/", s.authMiddleware())
|
||||
@@ -127,9 +202,6 @@ func (s *Server) setupRoutes() {
|
||||
s.route(protected, "POST", "/logout", "Logout (blacklist token)", s.handleLogout)
|
||||
s.route(protected, "POST", "/onboarding/beginner", "Prepare beginner claw402 wallet and default model", s.handleBeginnerOnboarding)
|
||||
s.route(protected, "GET", "/onboarding/beginner/current", "Get current beginner claw402 wallet", s.handleCurrentBeginnerWallet)
|
||||
s.route(protected, "GET", "/agent/preferences", "Get persistent agent preferences", s.handleGetAgentPreferences)
|
||||
s.route(protected, "POST", "/agent/preferences", "Create persistent agent preference", s.handleCreateAgentPreference)
|
||||
s.route(protected, "DELETE", "/agent/preferences/:id", "Delete persistent agent preference", s.handleDeleteAgentPreference)
|
||||
|
||||
// User account management
|
||||
s.routeWithSchema(protected, "PUT", "/user/password", "Change current user password",
|
||||
@@ -139,6 +211,11 @@ func (s *Server) setupRoutes() {
|
||||
// Server IP query (requires authentication, for whitelist configuration)
|
||||
s.route(protected, "GET", "/server-ip", "Get server public IP (for exchange whitelist)", s.handleGetServerIP)
|
||||
|
||||
s.route(protected, "GET", "/vergex/signal-ranking", "Vergex signal ranking via claw402 (?marketType=all&limit=30)", s.handleVergexSignalRanking)
|
||||
s.route(protected, "GET", "/vergex/signal-lab", "Vergex signal lab via claw402 (?marketType=hip3_perp&symbol=AAPL)", s.handleVergexSignalLab)
|
||||
s.route(protected, "GET", "/vergex/cost-liquidation-heatmap", "Vergex cost/liquidation heatmap via claw402 (?marketType=hip3_perp&symbol=AAPL)", s.handleVergexCostLiquidationHeatmap)
|
||||
s.route(protected, "GET", "/vergex/flow-markets", "Vergex net-flow market ranking via claw402 (?chain=mainnet&window=1h&limit=25)", s.handleVergexFlowMarkets)
|
||||
|
||||
// AI trader management
|
||||
s.routeWithSchema(protected, "GET", "/my-traders", "List user's traders with status",
|
||||
`Returns: [{"trader_id":"<EXACT id — use this as trader_id in all ?trader_id= queries and POST /traders/:id/start|stop>","trader_name":"<string>","is_running":<bool>}]
|
||||
@@ -148,7 +225,7 @@ NOTE: The id field is "trader_id" (NOT "id"). Always read trader_id from this en
|
||||
`:id = trader_id from GET /api/my-traders`,
|
||||
s.handleGetTraderConfig)
|
||||
s.routeWithSchema(protected, "POST", "/traders", "Create a new AI trader",
|
||||
`Body: {"name":"<string, required>","ai_model_id":"<EXACT id field from GET /api/models — e.g. 'abc123_deepseek', NOT the provider name 'deepseek'>","exchange_id":"<EXACT id field from GET /api/exchanges — e.g. '05785d3b-841e-...', NOT the type name>","strategy_id":"<EXACT id field from GET /api/strategies>","scan_interval_minutes":<int, default 3, minimum 3>}
|
||||
`Body: {"name":"<string, required>","ai_model_id":"<EXACT id field from GET /api/models — e.g. 'abc123_deepseek', NOT the provider name 'deepseek'>","exchange_id":"<EXACT id field from GET /api/exchanges — e.g. '05785d3b-841e-...', NOT the type name>","strategy_id":"<EXACT id field from GET /api/strategies>","scan_interval_minutes":<int, default 15, minimum 3>}
|
||||
IMPORTANT: ai_model_id and exchange_id must be the full "id" value from the Account State, not the provider/type name.`,
|
||||
s.handleCreateTrader)
|
||||
s.routeWithSchema(protected, "PUT", "/traders/:id", "Update trader configuration",
|
||||
@@ -160,8 +237,17 @@ Only include fields you want to change.`,
|
||||
`:id = trader_id from GET /api/my-traders. Stops and permanently removes the trader and all its data.`,
|
||||
s.handleDeleteTrader)
|
||||
s.routeWithSchema(protected, "POST", "/traders/:id/start", "Start trader — begins live trading",
|
||||
`:id = trader_id from GET /api/my-traders. No request body needed. The trader must have a valid exchange and AI model configured.`,
|
||||
`:id = trader_id from GET /api/my-traders. No request body needed. The trader must have a valid exchange and AI model configured.
|
||||
Runs launch preflight first and returns 400 with {"error_key":"trader.start.preflight_failed","preflight":{...}} when checks fail. Append ?force=true to skip balance gates.`,
|
||||
s.handleStartTrader)
|
||||
s.routeWithSchema(protected, "GET", "/traders/:id/preflight", "Run launch readiness checks for a trader",
|
||||
`:id = trader_id from GET /api/my-traders.
|
||||
Returns: {"ready":<bool>,"checks":[{"id":"ai_model|ai_wallet|ai_wallet_funds|strategy|exchange_config|exchange_account|exchange_funds","status":"ok|failed|warning|skipped","code":"<string>","message":"<string>","required":<number>,"actual":<number>,"asset":"<string>","address":"<string>"}],"min_ai_fee_usdc":<number>,"min_trading_usdc":<number>}`,
|
||||
s.handleTraderPreflight)
|
||||
s.routeWithSchema(protected, "POST", "/launch/preflight", "Run launch readiness checks before creating a trader",
|
||||
`Body: {"ai_model_id":"<EXACT id from GET /api/models>","exchange_id":"<EXACT id from GET /api/exchanges>","strategy_id":"<optional, EXACT id from GET /api/strategies>"}
|
||||
Returns the same shape as GET /api/traders/:id/preflight. Use this before POST /api/traders to surface every missing prerequisite at once.`,
|
||||
s.handleLaunchPreflight)
|
||||
s.routeWithSchema(protected, "POST", "/traders/:id/stop", "Stop trader — halts live trading",
|
||||
`:id = trader_id from GET /api/my-traders. No request body needed. Gracefully stops the trading loop.`,
|
||||
s.handleStopTrader)
|
||||
@@ -259,10 +345,10 @@ CRITICAL: Always use the "id" field for strategy_id.`,
|
||||
IMPORTANT: For most use cases just POST {"name":"<name>"} — the backend fills everything in. Only include "config" when the user explicitly requests custom settings (specific coins, custom leverage, custom timeframes).
|
||||
|
||||
StrategyConfig fields:
|
||||
coin_source.source_type: "static"(fixed coin list) | "ai500"(AI top500 ranking) | "oi_top"(OI increasing, suited for long) | "oi_low"(OI decreasing, suited for short) | "mixed"
|
||||
coin_source.static_coins: ["BTCUSDT","ETHUSDT"] — only when source_type="static"
|
||||
coin_source.use_ai500, ai500_limit: number of coins from AI500 pool (default 10)
|
||||
coin_source.use_oi_top/use_oi_low, oi_top_limit/oi_low_limit: OI-based coin selection
|
||||
coin_source.source_type: "vergex_signal" (Claw402/Vergex signal-ranking; default and recommended)
|
||||
coin_source.vergex_limit: number of Claw402 candidates enriched with detail data (default 10, max 10)
|
||||
coin_source.vergex_market_type: "all" for the full Claw402 board; detail calls use each ranking item's market_type
|
||||
coin_source.vergex_chain: "hyperliquid"
|
||||
indicators.klines.primary_timeframe: "1m"|"3m"|"5m"|"15m"|"1h"|"4h" — scalping→"5m", trend/swing→"1h"/"4h"
|
||||
indicators.klines.primary_count: number of candles (20-100)
|
||||
indicators.klines.enable_multi_timeframe: true for trend/swing analysis
|
||||
@@ -359,6 +445,10 @@ Returns the most recent AI decision for each symbol analyzed in the last scan cy
|
||||
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
|
||||
Returns: {"total_trades":<int>,"winning_trades":<int>,"win_rate":<float>,"total_pnl":<float>,"sharpe_ratio":<float>,"max_drawdown":<float>}`,
|
||||
s.handleStatistics)
|
||||
s.routeWithSchema(protected, "GET", "/statistics/full", "Full trade-quality metrics (win rate, profit factor, Sharpe, max drawdown)",
|
||||
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
|
||||
Returns: {"total_trades","win_trades","loss_trades","win_rate","profit_factor","sharpe_ratio","total_pnl","total_fee","avg_win","avg_loss","max_drawdown_pct"}`,
|
||||
s.handleStatisticsFull)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -511,35 +601,46 @@ func isPrivateIP(ip net.IP) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// getTraderFromQuery Get trader from query parameter
|
||||
// getTraderFromQuery resolves a trader from the ?trader_id= query parameter,
|
||||
// strictly scoped to the authenticated caller.
|
||||
//
|
||||
// Ownership is always enforced against the caller's own trader list in the
|
||||
// store. We deliberately never fall back to the global in-memory trader map
|
||||
// (TraderManager holds every account's traders): returning an entry from it for
|
||||
// a trader the caller does not own is a cross-tenant data leak (IDOR) — a
|
||||
// freshly-registered user with no traders of their own could otherwise pass any
|
||||
// other account's trader_id and read its balance, positions and AI decisions.
|
||||
func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, string, error) {
|
||||
userID := c.GetString("user_id")
|
||||
traderID := c.Query("trader_id")
|
||||
|
||||
// Ensure user's traders are loaded into memory
|
||||
err := s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||
if err != nil {
|
||||
// Ensure user's traders are loaded into memory.
|
||||
if err := s.traderManager.LoadUserTradersFromStore(s.store, userID); err != nil {
|
||||
logger.Infof("⚠️ Failed to load traders for user %s: %v", userID, err)
|
||||
}
|
||||
|
||||
if traderID == "" {
|
||||
// If no trader_id specified, return first trader for this user
|
||||
ids := s.traderManager.GetTraderIDs()
|
||||
if len(ids) == 0 {
|
||||
// Resolve strictly from the caller's own trader list.
|
||||
userTraders, err := s.store.Trader().List(userID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to load traders for this account: %w", err)
|
||||
}
|
||||
if len(userTraders) == 0 {
|
||||
return nil, "", fmt.Errorf("No available traders")
|
||||
}
|
||||
|
||||
// Get user's trader list, prioritize returning user's own traders
|
||||
userTraders, err := s.store.Trader().List(userID)
|
||||
if err == nil && len(userTraders) > 0 {
|
||||
traderID = userTraders[0].ID
|
||||
} else {
|
||||
traderID = ids[0]
|
||||
}
|
||||
if traderID == "" {
|
||||
// No trader_id specified — default to the caller's first trader.
|
||||
return s.traderManager, userTraders[0].ID, nil
|
||||
}
|
||||
|
||||
// A trader_id was supplied — it must belong to the caller.
|
||||
for _, t := range userTraders {
|
||||
if t.ID == traderID {
|
||||
return s.traderManager, traderID, nil
|
||||
}
|
||||
}
|
||||
return nil, "", fmt.Errorf("trader not found for this account")
|
||||
}
|
||||
|
||||
// authMiddleware JWT authentication middleware
|
||||
func (s *Server) authMiddleware() gin.HandlerFunc {
|
||||
|
||||
@@ -2,11 +2,38 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"nofx/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestPublicDecryptRouteNotRegistered is a security regression test: the
|
||||
// unauthenticated POST /api/crypto/decrypt route was a decryption oracle and
|
||||
// must never be re-registered. A built server's router must not route to it.
|
||||
func TestPublicDecryptRouteNotRegistered(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
s := &Server{router: gin.New()}
|
||||
s.setupRoutes()
|
||||
|
||||
for _, r := range s.router.Routes() {
|
||||
if r.Method == http.MethodPost && r.Path == "/api/crypto/decrypt" {
|
||||
t.Fatalf("SECURITY REGRESSION: public decryption oracle POST /api/crypto/decrypt is registered")
|
||||
}
|
||||
}
|
||||
|
||||
// Also assert at the HTTP layer that the route is not handled.
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/crypto/decrypt", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for POST /api/crypto/decrypt, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateTraderRequest_SystemPromptTemplate Test whether SystemPromptTemplate field exists when updating trader
|
||||
func TestUpdateTraderRequest_SystemPromptTemplate(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -20,6 +21,9 @@ import (
|
||||
// validateStrategyConfig validates strategy configuration and returns warnings
|
||||
func validateStrategyConfig(config *store.StrategyConfig) []string {
|
||||
var warnings []string
|
||||
if config.StrategyType == "grid_trading" {
|
||||
return warnings
|
||||
}
|
||||
|
||||
// Validate NofxOS API key if any NofxOS feature is enabled
|
||||
if (config.Indicators.EnableQuantData || config.Indicators.EnableOIRanking ||
|
||||
@@ -31,6 +35,17 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
|
||||
return warnings
|
||||
}
|
||||
|
||||
func attachPublishConfig(config *store.StrategyConfig, strategy *store.Strategy) {
|
||||
if config == nil || strategy == nil {
|
||||
return
|
||||
}
|
||||
config.ClampLimits()
|
||||
config.PublishConfig = &store.PublishStrategyConfig{
|
||||
IsPublic: strategy.IsPublic,
|
||||
ConfigVisible: strategy.ConfigVisible,
|
||||
}
|
||||
}
|
||||
|
||||
// handleEstimateTokens estimates token usage for a strategy config (no auth required, pure computation)
|
||||
func (s *Server) handleEstimateTokens(c *gin.Context) {
|
||||
var req struct {
|
||||
@@ -71,6 +86,7 @@ func (s *Server) handlePublicStrategies(c *gin.Context) {
|
||||
if st.ConfigVisible {
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(st.Config), &config)
|
||||
attachPublishConfig(&config, st)
|
||||
item["config"] = config
|
||||
}
|
||||
|
||||
@@ -90,6 +106,14 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
lang := c.Query("lang")
|
||||
if lang == "" {
|
||||
lang = "zh"
|
||||
}
|
||||
if err := s.createDefaultStrategies(userID, lang); err != nil {
|
||||
logger.Warnf("Failed to sync default strategy presets for user %s: %v", userID, err)
|
||||
}
|
||||
|
||||
strategies, err := s.store.Strategy().List(userID)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to get strategy list", err)
|
||||
@@ -101,6 +125,7 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
|
||||
for _, st := range strategies {
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(st.Config), &config)
|
||||
attachPublishConfig(&config, st)
|
||||
|
||||
result = append(result, gin.H{
|
||||
"id": st.ID,
|
||||
@@ -139,6 +164,7 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
|
||||
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(strategy.Config), &config)
|
||||
attachPublishConfig(&config, strategy)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": strategy.ID,
|
||||
@@ -166,6 +192,8 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
Description string `json:"description"`
|
||||
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
|
||||
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
|
||||
IsPublic bool `json:"is_public"`
|
||||
ConfigVisible bool `json:"config_visible"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -182,6 +210,19 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
defaultCfg := store.GetDefaultStrategyConfig(lang)
|
||||
req.Config = &defaultCfg
|
||||
}
|
||||
beforeClamp := *req.Config
|
||||
req.Config.ClampLimits()
|
||||
hadPublishConfig := req.Config.PublishConfig != nil
|
||||
isPublic := req.IsPublic
|
||||
configVisible := req.ConfigVisible
|
||||
if hadPublishConfig {
|
||||
isPublic = req.Config.PublishConfig.IsPublic
|
||||
configVisible = req.Config.PublishConfig.ConfigVisible
|
||||
}
|
||||
req.Config.PublishConfig = &store.PublishStrategyConfig{
|
||||
IsPublic: isPublic,
|
||||
ConfigVisible: configVisible,
|
||||
}
|
||||
|
||||
// Serialize configuration
|
||||
configJSON, err := json.Marshal(req.Config)
|
||||
@@ -197,6 +238,9 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
Description: req.Description,
|
||||
IsActive: false,
|
||||
IsDefault: false,
|
||||
IsPublic: isPublic,
|
||||
// Existing default is true; keep that behavior when no explicit publish config is sent.
|
||||
ConfigVisible: configVisible || !hadPublishConfig,
|
||||
Config: string(configJSON),
|
||||
}
|
||||
|
||||
@@ -207,6 +251,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
|
||||
// Validate configuration and collect warnings
|
||||
warnings := validateStrategyConfig(req.Config)
|
||||
warnings = append(warnings, store.StrategyClampWarnings(beforeClamp, *req.Config, req.Config.Language)...)
|
||||
|
||||
response := gin.H{
|
||||
"id": strategy.ID,
|
||||
@@ -263,14 +308,21 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
mergedConfig = store.StrategyConfig{}
|
||||
}
|
||||
|
||||
// Apply incoming config on top: top-level sections present in the request overwrite
|
||||
// their corresponding existing section; absent sections remain unchanged.
|
||||
// Apply incoming config on top while preserving nested fields that were not sent.
|
||||
if len(req.Config) > 0 && string(req.Config) != "null" {
|
||||
if err := json.Unmarshal(req.Config, &mergedConfig); err != nil {
|
||||
var patch map[string]any
|
||||
if err := json.Unmarshal(req.Config, &patch); err != nil {
|
||||
SafeBadRequest(c, "Invalid config JSON")
|
||||
return
|
||||
}
|
||||
mergedConfig, err = store.MergeStrategyConfig(mergedConfig, patch)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid config JSON")
|
||||
return
|
||||
}
|
||||
}
|
||||
beforeClamp := mergedConfig
|
||||
mergedConfig.ClampLimits()
|
||||
|
||||
// Preserve existing name/description when not supplied
|
||||
name := req.Name
|
||||
@@ -324,6 +376,7 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
|
||||
// Validate merged configuration and collect warnings
|
||||
warnings := validateStrategyConfig(&mergedConfig)
|
||||
warnings = append(warnings, store.StrategyClampWarnings(beforeClamp, mergedConfig, mergedConfig.Language)...)
|
||||
|
||||
response := gin.H{"message": "Strategy updated successfully"}
|
||||
if len(warnings) > 0 {
|
||||
@@ -417,6 +470,7 @@ func (s *Server) handleGetActiveStrategy(c *gin.Context) {
|
||||
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(strategy.Config), &config)
|
||||
attachPublishConfig(&config, strategy)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": strategy.ID,
|
||||
@@ -583,6 +637,7 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
symbols = append(symbols, c.Symbol)
|
||||
}
|
||||
quantDataMap := engine.FetchQuantDataBatch(symbols)
|
||||
vergexDataMap := engine.FetchVergexDataBatch(context.Background(), symbols)
|
||||
|
||||
// Fetch OI ranking data (market-wide position changes)
|
||||
oiRankingData := engine.FetchOIRankingData()
|
||||
@@ -613,6 +668,7 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
PromptVariant: req.PromptVariant,
|
||||
MarketDataMap: marketDataMap,
|
||||
QuantDataMap: quantDataMap,
|
||||
VergexDataMap: vergexDataMap,
|
||||
OIRankingData: oiRankingData,
|
||||
NetFlowRankingData: netFlowRankingData,
|
||||
PriceRankingData: priceRankingData,
|
||||
|
||||
36
api/utils.go
36
api/utils.go
@@ -15,13 +15,10 @@ func MaskSensitiveString(s string) string {
|
||||
return s[:4] + "****" + s[length-4:]
|
||||
}
|
||||
|
||||
// SanitizeModelConfigForLog Sanitize model configuration for log output
|
||||
func SanitizeModelConfigForLog(models map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
CustomAPIURL string `json:"custom_api_url"`
|
||||
CustomModelName string `json:"custom_model_name"`
|
||||
}) map[string]interface{} {
|
||||
// SanitizeModelConfigForLog Sanitize model configuration for log output.
|
||||
// Takes the same ModelConfigUpdate type used by the request handler so the two
|
||||
// can never drift out of sync.
|
||||
func SanitizeModelConfigForLog(models map[string]ModelConfigUpdate) map[string]interface{} {
|
||||
safe := make(map[string]interface{})
|
||||
for modelID, cfg := range models {
|
||||
safe[modelID] = map[string]interface{}{
|
||||
@@ -34,19 +31,12 @@ func SanitizeModelConfigForLog(models map[string]struct {
|
||||
return safe
|
||||
}
|
||||
|
||||
// SanitizeExchangeConfigForLog Sanitize exchange configuration for log output
|
||||
func SanitizeExchangeConfigForLog(exchanges map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
}) map[string]interface{} {
|
||||
// SanitizeExchangeConfigForLog Sanitize exchange configuration for log output.
|
||||
// Takes the same ExchangeConfigUpdate type used by the request handler so every
|
||||
// sensitive field is guaranteed to be masked — adding a field to the request
|
||||
// type without masking it here would not compile around this helper, but more
|
||||
// importantly keeps the masking exhaustive.
|
||||
func SanitizeExchangeConfigForLog(exchanges map[string]ExchangeConfigUpdate) map[string]interface{} {
|
||||
safe := make(map[string]interface{})
|
||||
for exchangeID, cfg := range exchanges {
|
||||
safeExchange := map[string]interface{}{
|
||||
@@ -61,12 +51,18 @@ func SanitizeExchangeConfigForLog(exchanges map[string]struct {
|
||||
if cfg.SecretKey != "" {
|
||||
safeExchange["secret_key"] = MaskSensitiveString(cfg.SecretKey)
|
||||
}
|
||||
if cfg.Passphrase != "" {
|
||||
safeExchange["passphrase"] = MaskSensitiveString(cfg.Passphrase)
|
||||
}
|
||||
if cfg.AsterPrivateKey != "" {
|
||||
safeExchange["aster_private_key"] = MaskSensitiveString(cfg.AsterPrivateKey)
|
||||
}
|
||||
if cfg.LighterPrivateKey != "" {
|
||||
safeExchange["lighter_private_key"] = MaskSensitiveString(cfg.LighterPrivateKey)
|
||||
}
|
||||
if cfg.LighterAPIKeyPrivateKey != "" {
|
||||
safeExchange["lighter_api_key_private_key"] = MaskSensitiveString(cfg.LighterAPIKeyPrivateKey)
|
||||
}
|
||||
|
||||
// Add non-sensitive fields directly
|
||||
if cfg.HyperliquidWalletAddr != "" {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -48,12 +50,7 @@ func TestMaskSensitiveString(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSanitizeModelConfigForLog(t *testing.T) {
|
||||
models := map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
CustomAPIURL string `json:"custom_api_url"`
|
||||
CustomModelName string `json:"custom_model_name"`
|
||||
}{
|
||||
models := map[string]ModelConfigUpdate{
|
||||
"deepseek": {
|
||||
Enabled: true,
|
||||
APIKey: "sk-1234567890abcdefghijklmnopqrstuvwxyz",
|
||||
@@ -88,32 +85,29 @@ func TestSanitizeModelConfigForLog(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSanitizeExchangeConfigForLog(t *testing.T) {
|
||||
exchanges := map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
}{
|
||||
exchanges := map[string]ExchangeConfigUpdate{
|
||||
"binance": {
|
||||
Enabled: true,
|
||||
APIKey: "binance_api_key_1234567890abcdef",
|
||||
SecretKey: "binance_secret_key_1234567890abcdef",
|
||||
Testnet: false,
|
||||
LighterWalletAddr: "",
|
||||
LighterPrivateKey: "",
|
||||
},
|
||||
"okx": {
|
||||
Enabled: true,
|
||||
APIKey: "okx_api_key_1234567890abcdef",
|
||||
SecretKey: "okx_secret_key_1234567890abcdef",
|
||||
Passphrase: "okx_passphrase_supersecret_value",
|
||||
},
|
||||
"lighter": {
|
||||
Enabled: true,
|
||||
LighterWalletAddr: "0xabcdef0000000000000000000000000000000000",
|
||||
LighterPrivateKey: "lighter_private_key_1234567890abcdef",
|
||||
LighterAPIKeyPrivateKey: "lighter_api_key_private_key_1234567890abcdef",
|
||||
},
|
||||
"hyperliquid": {
|
||||
Enabled: true,
|
||||
HyperliquidWalletAddr: "0x1234567890abcdef1234567890abcdef12345678",
|
||||
Testnet: false,
|
||||
LighterWalletAddr: "",
|
||||
LighterPrivateKey: "",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -143,6 +137,32 @@ func TestSanitizeExchangeConfigForLog(t *testing.T) {
|
||||
t.Errorf("expected masked secret_key='bina****cdef', got %q", maskedSecretKey)
|
||||
}
|
||||
|
||||
// Check OKX passphrase is masked (regression: previously not covered)
|
||||
okxConfig, ok := result["okx"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("okx config not found or wrong type")
|
||||
}
|
||||
maskedPassphrase, ok := okxConfig["passphrase"].(string)
|
||||
if !ok {
|
||||
t.Fatal("okx passphrase not found or wrong type")
|
||||
}
|
||||
if maskedPassphrase != "okx_****alue" {
|
||||
t.Errorf("expected masked passphrase='okx_****alue', got %q", maskedPassphrase)
|
||||
}
|
||||
|
||||
// Check Lighter API key private key is masked (regression: previously not covered)
|
||||
lighterConfig, ok := result["lighter"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("lighter config not found or wrong type")
|
||||
}
|
||||
maskedLighterAPIKey, ok := lighterConfig["lighter_api_key_private_key"].(string)
|
||||
if !ok {
|
||||
t.Fatal("lighter_api_key_private_key not found or wrong type")
|
||||
}
|
||||
if maskedLighterAPIKey != "ligh****cdef" {
|
||||
t.Errorf("expected masked lighter_api_key_private_key='ligh****cdef', got %q", maskedLighterAPIKey)
|
||||
}
|
||||
|
||||
// Check Hyperliquid configuration
|
||||
hlConfig, ok := result["hyperliquid"].(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -160,6 +180,41 @@ func TestSanitizeExchangeConfigForLog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeExchangeConfigForLog_NoPlaintextSecrets renders the sanitized log
|
||||
// output exactly as the handler does (`%+v`) and asserts that no plaintext
|
||||
// secret — including the passphrase and lighter API key private key that were
|
||||
// historically not redacted — survives into the log line.
|
||||
func TestSanitizeExchangeConfigForLog_NoPlaintextSecrets(t *testing.T) {
|
||||
secrets := map[string]string{
|
||||
"api_key": "binance_api_key_1234567890abcdef",
|
||||
"secret_key": "binance_secret_key_1234567890abcdef",
|
||||
"passphrase": "okx_passphrase_supersecret_value",
|
||||
"aster_private_key": "aster_private_key_1234567890abcdef",
|
||||
"lighter_private_key": "lighter_private_key_1234567890abcdef",
|
||||
"lighter_api_key_private_key": "lighter_api_key_private_key_1234567890abcdef",
|
||||
}
|
||||
|
||||
exchanges := map[string]ExchangeConfigUpdate{
|
||||
"okx": {
|
||||
Enabled: true,
|
||||
APIKey: secrets["api_key"],
|
||||
SecretKey: secrets["secret_key"],
|
||||
Passphrase: secrets["passphrase"],
|
||||
AsterPrivateKey: secrets["aster_private_key"],
|
||||
LighterPrivateKey: secrets["lighter_private_key"],
|
||||
LighterAPIKeyPrivateKey: secrets["lighter_api_key_private_key"],
|
||||
},
|
||||
}
|
||||
|
||||
rendered := fmt.Sprintf("%+v", SanitizeExchangeConfigForLog(exchanges))
|
||||
|
||||
for field, secret := range secrets {
|
||||
if strings.Contains(rendered, secret) {
|
||||
t.Errorf("sanitized log leaked plaintext %s: %q present in %q", field, secret, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
228
cli.go
Normal file
228
cli.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"nofx/auth"
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"golang.org/x/term"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// minResetPasswordLen mirrors the minimum enforced on the authenticated
|
||||
// password-change path (PUT /api/user/password).
|
||||
const minResetPasswordLen = 8
|
||||
|
||||
// runCLISubcommand dispatches local admin subcommands.
|
||||
//
|
||||
// SECURITY: account recovery (reset-password, reset-account) is intentionally
|
||||
// NOT exposed over HTTP. Performing it requires running this binary on the host,
|
||||
// which in turn requires shell/file access to the server. A remote attacker on a
|
||||
// public-facing deployment has only the network — they can reach the API but not
|
||||
// a local process — so recovery cannot be triggered remotely. This is what makes
|
||||
// the recovery path safe even when NOFX is deployed on the public internet.
|
||||
//
|
||||
// Returns true if a subcommand was recognized and handled (caller should exit).
|
||||
// Unknown first args fall through to false to preserve the historical behavior
|
||||
// where `nofx <dbpath>` overrides the SQLite path.
|
||||
func runCLISubcommand(args []string) bool {
|
||||
if len(args) == 0 {
|
||||
return false
|
||||
}
|
||||
switch args[0] {
|
||||
case "reset-password":
|
||||
runResetPassword(args[1:])
|
||||
return true
|
||||
case "reset-account":
|
||||
runResetAccount(args[1:])
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// openStoreForCLI loads config + encryption and opens the same database the
|
||||
// server uses, so subcommands operate on the live data.
|
||||
func openStoreForCLI(dbPathOverride string) (*store.Store, error) {
|
||||
_ = godotenv.Load()
|
||||
logger.Init(nil)
|
||||
config.MustInit()
|
||||
cfg := config.Get()
|
||||
if strings.TrimSpace(dbPathOverride) != "" {
|
||||
cfg.DBPath = dbPathOverride
|
||||
}
|
||||
|
||||
cryptoService, err := crypto.NewCryptoService()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initialize encryption service: %w", err)
|
||||
}
|
||||
crypto.SetGlobalCryptoService(cryptoService)
|
||||
|
||||
if cfg.DBType == "sqlite" {
|
||||
if dir := filepath.Dir(cfg.DBPath); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create data directory: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dbType := store.DBTypeSQLite
|
||||
if cfg.DBType == "postgres" {
|
||||
dbType = store.DBTypePostgres
|
||||
}
|
||||
return store.NewWithConfig(store.DBConfig{
|
||||
Type: dbType,
|
||||
Path: cfg.DBPath,
|
||||
Host: cfg.DBHost,
|
||||
Port: cfg.DBPort,
|
||||
User: cfg.DBUser,
|
||||
Password: cfg.DBPassword,
|
||||
DBName: cfg.DBName,
|
||||
SSLMode: cfg.DBSSLMode,
|
||||
})
|
||||
}
|
||||
|
||||
// runResetPassword resets the password for a single account from the command
|
||||
// line. Usage: `nofx reset-password --email you@example.com`.
|
||||
func runResetPassword(args []string) {
|
||||
fs := flag.NewFlagSet("reset-password", flag.ExitOnError)
|
||||
email := fs.String("email", "", "email of the account to reset (required)")
|
||||
password := fs.String("password", "", "new password (min 8 chars); omit to enter it interactively")
|
||||
dbPath := fs.String("db", "", "override SQLite DB path (defaults to config / DB_PATH)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
if strings.TrimSpace(*email) == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: --email is required")
|
||||
fmt.Fprintln(os.Stderr, "usage: nofx reset-password --email you@example.com")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
st, err := openStoreForCLI(*dbPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
user, err := st.User().GetByEmail(strings.TrimSpace(*email))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: no account found for %q\n", strings.TrimSpace(*email))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
newPassword, err := resolveNewPassword(*password)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to hash password: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := st.User().UpdatePassword(user.ID, hash); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to update password: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Password reset for %s. Log in with the new password.\n", user.Email)
|
||||
}
|
||||
|
||||
// runResetAccount wipes the database back to an uninitialized state. This is the
|
||||
// destructive "forgot everything" recovery, moved off the public API.
|
||||
func runResetAccount(args []string) {
|
||||
fs := flag.NewFlagSet("reset-account", flag.ExitOnError)
|
||||
dbPath := fs.String("db", "", "override SQLite DB path (defaults to config / DB_PATH)")
|
||||
yes := fs.Bool("yes", false, "skip the interactive confirmation prompt")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
if !*yes {
|
||||
fmt.Print("This permanently deletes ALL users, traders, strategies, AI models and\n" +
|
||||
"exchanges — including wallet keys and exchange credentials.\n" +
|
||||
"Type 'wipe' to confirm: ")
|
||||
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if strings.TrimSpace(line) != "wipe" {
|
||||
fmt.Fprintln(os.Stderr, "aborted")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
st, err := openStoreForCLI(*dbPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
err = st.Transaction(func(tx *gorm.DB) error {
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{})
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{})
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.AIModel{})
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Exchange{})
|
||||
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete users: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to reset account: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("✓ System wiped. Register a fresh account and re-import everything.")
|
||||
}
|
||||
|
||||
// resolveNewPassword returns the new password from the --password flag, or
|
||||
// prompts for it (hidden) on a TTY, or reads a single line from piped stdin.
|
||||
func resolveNewPassword(flagValue string) (string, error) {
|
||||
if flagValue != "" {
|
||||
if len(flagValue) < minResetPasswordLen {
|
||||
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
|
||||
}
|
||||
return flagValue, nil
|
||||
}
|
||||
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
fmt.Printf("New password (min %d chars): ", minResetPasswordLen)
|
||||
first, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
fmt.Print("Confirm new password: ")
|
||||
second, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
if string(first) != string(second) {
|
||||
return "", errors.New("passwords do not match")
|
||||
}
|
||||
if len(first) < minResetPasswordLen {
|
||||
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
|
||||
}
|
||||
return string(first), nil
|
||||
}
|
||||
|
||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
password := strings.TrimRight(line, "\r\n")
|
||||
if password == "" {
|
||||
return "", fmt.Errorf("no password provided on stdin: %w", err)
|
||||
}
|
||||
if len(password) < minResetPasswordLen {
|
||||
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
120
cmd/e2e_builder_fee/main.go
Normal file
120
cmd/e2e_builder_fee/main.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"nofx/config"
|
||||
nofxcrypto "nofx/crypto"
|
||||
"nofx/store"
|
||||
hltrader "nofx/trader/hyperliquid"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type clearinghouseState struct {
|
||||
CrossMarginSummary struct {
|
||||
AccountValue string `json:"accountValue"`
|
||||
} `json:"crossMarginSummary"`
|
||||
Withdrawable string `json:"withdrawable"`
|
||||
AssetPositions []struct {
|
||||
Position struct {
|
||||
Coin string `json:"coin"`
|
||||
Szi string `json:"szi"`
|
||||
EntryPx string `json:"entryPx"`
|
||||
PositionValue string `json:"positionValue"`
|
||||
} `json:"position"`
|
||||
} `json:"assetPositions"`
|
||||
}
|
||||
|
||||
func fetchState(wallet string) (*clearinghouseState, error) {
|
||||
body := strings.NewReader(fmt.Sprintf(`{"type":"clearinghouseState","user":%q}`, wallet))
|
||||
resp, err := http.Post("https://api.hyperliquid.xyz/info", "application/json", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var state clearinghouseState
|
||||
if err := json.NewDecoder(resp.Body).Decode(&state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func positionSize(state *clearinghouseState, coin string) float64 {
|
||||
for _, ap := range state.AssetPositions {
|
||||
if strings.EqualFold(ap.Position.Coin, coin) {
|
||||
v, _ := strconv.ParseFloat(ap.Position.Szi, 64)
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
config.Init()
|
||||
cryptoService, err := nofxcrypto.NewCryptoService()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
nofxcrypto.SetGlobalCryptoService(cryptoService)
|
||||
cfg := config.Get()
|
||||
st, err := store.NewWithConfig(store.DBConfig{Type: store.DBTypeSQLite, Path: cfg.DBPath})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
var ex store.Exchange
|
||||
if err := st.GormDB().Where("exchange_type = ? AND enabled = ? AND hyperliquid_wallet_addr <> ''", "hyperliquid", true).First(&ex).Error; err != nil {
|
||||
panic(fmt.Errorf("no enabled Hyperliquid exchange with wallet/private key found: %w", err))
|
||||
}
|
||||
if strings.TrimSpace(string(ex.APIKey)) == "" {
|
||||
panic("Hyperliquid exchange has empty decrypted agent private key")
|
||||
}
|
||||
|
||||
fmt.Printf("E2E exchange=%s account=%s wallet=%s testnet=%v builderApprovedFlag=%v\n", ex.ID, ex.AccountName, ex.HyperliquidWalletAddr, ex.Testnet, ex.HyperliquidBuilderApproved)
|
||||
before, err := fetchState(ex.HyperliquidWalletAddr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("BEFORE accountValue=%s withdrawable=%s HOOD_szi=%.6f\n", before.CrossMarginSummary.AccountValue, before.Withdrawable, positionSize(before, "xyz:HOOD"))
|
||||
|
||||
tr, err := hltrader.NewHyperliquidTrader(string(ex.APIKey), ex.HyperliquidWalletAddr, false, ex.HyperliquidUnifiedAcct)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
const symbol = "HOOD-USDC"
|
||||
const qty = 0.15
|
||||
fmt.Printf("OPEN_LONG symbol=%s qty=%.3f builderRequired=true\n", symbol, qty)
|
||||
if _, err := tr.OpenLong(symbol, qty, 1); err != nil {
|
||||
panic(fmt.Errorf("open long failed: %w", err))
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
mid, _ := fetchState(ex.HyperliquidWalletAddr)
|
||||
pos := positionSize(mid, "xyz:HOOD")
|
||||
fmt.Printf("AFTER_OPEN HOOD_szi=%.6f\n", pos)
|
||||
closeQty := qty
|
||||
if pos > 0 && pos < closeQty {
|
||||
closeQty = pos
|
||||
}
|
||||
if closeQty > 0 {
|
||||
fmt.Printf("CLOSE_LONG symbol=%s qty=%.6f builderRequired=true\n", symbol, closeQty)
|
||||
if _, err := tr.CloseLong(symbol, closeQty); err != nil {
|
||||
panic(fmt.Errorf("close long failed; manual intervention may be needed for %s size %.6f: %w", symbol, closeQty, err))
|
||||
}
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
after, err := fetchState(ex.HyperliquidWalletAddr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("AFTER_CLOSE accountValue=%s withdrawable=%s HOOD_szi=%.6f\n", after.CrossMarginSummary.AccountValue, after.Withdrawable, positionSize(after, "xyz:HOOD"))
|
||||
fmt.Fprintln(os.Stdout, "E2E_BUILDER_FEE_REAL_XYZ_STOCK_TRADE_DONE")
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"nofx/telemetry"
|
||||
"fmt"
|
||||
"nofx/mcp"
|
||||
"nofx/telemetry"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// insecureDefaultJWTSecret is the historical fallback value. Refusing to boot when
|
||||
// JWT_SECRET matches it (or is missing) prevents the server from silently signing
|
||||
// tokens with a well-known secret.
|
||||
const insecureDefaultJWTSecret = "default-jwt-secret-change-in-production"
|
||||
|
||||
// minJWTSecretLength is the minimum byte length we accept for HS256 signing keys.
|
||||
// HS256 keys shorter than 32 bytes are brute-forceable.
|
||||
const minJWTSecretLength = 32
|
||||
|
||||
// Global configuration instance
|
||||
var global *Config
|
||||
|
||||
@@ -45,8 +55,24 @@ type Config struct {
|
||||
|
||||
}
|
||||
|
||||
// Init initializes global configuration (from .env)
|
||||
// MustInit initializes global configuration or panics. Use from main() so the
|
||||
// process refuses to start under an insecure config (e.g. default JWT secret).
|
||||
func MustInit() {
|
||||
if err := initConfig(); err != nil {
|
||||
panic(fmt.Sprintf("config: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes global configuration (from .env). Prefer MustInit from main.
|
||||
func Init() {
|
||||
if err := initConfig(); err != nil {
|
||||
// Preserve historical fail-soft behavior for non-main callers (tests, tools);
|
||||
// the process can still observe the error via Get() returning nil.
|
||||
fmt.Fprintf(os.Stderr, "config init failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func initConfig() error {
|
||||
cfg := &Config{
|
||||
APIServerPort: 8080,
|
||||
ExperienceImprovement: true, // Default: enabled to help improve the product
|
||||
@@ -65,7 +91,13 @@ func Init() {
|
||||
cfg.JWTSecret = strings.TrimSpace(v)
|
||||
}
|
||||
if cfg.JWTSecret == "" {
|
||||
cfg.JWTSecret = "default-jwt-secret-change-in-production"
|
||||
return fmt.Errorf("JWT_SECRET is required (set a random %d+ byte value in .env)", minJWTSecretLength)
|
||||
}
|
||||
if cfg.JWTSecret == insecureDefaultJWTSecret {
|
||||
return fmt.Errorf("JWT_SECRET matches the insecure default; generate a fresh random value (e.g. `openssl rand -base64 48`)")
|
||||
}
|
||||
if len(cfg.JWTSecret) < minJWTSecretLength {
|
||||
return fmt.Errorf("JWT_SECRET must be at least %d bytes (got %d); generate via `openssl rand -base64 48`", minJWTSecretLength, len(cfg.JWTSecret))
|
||||
}
|
||||
|
||||
if v := os.Getenv("API_SERVER_PORT"); v != "" {
|
||||
@@ -134,6 +166,7 @@ func Init() {
|
||||
OutputTokens: usage.CompletionTokens,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the global configuration
|
||||
|
||||
@@ -282,13 +282,17 @@ func isEncryptedStorageValue(value string) bool {
|
||||
}
|
||||
|
||||
func (cs *CryptoService) DecryptPayload(payload *EncryptedPayload) ([]byte, error) {
|
||||
// 1. Validate timestamp (prevent replay attacks)
|
||||
if payload.TS != 0 {
|
||||
// 1. Validate timestamp (prevent replay attacks).
|
||||
// The timestamp is mandatory: a missing/zero ts previously skipped this check
|
||||
// entirely, which let a captured ciphertext be replayed indefinitely. The
|
||||
// client (web/src/lib/crypto.ts) always stamps ts, so requiring it is safe.
|
||||
if payload.TS == 0 {
|
||||
return nil, errors.New("missing timestamp")
|
||||
}
|
||||
elapsed := time.Since(time.Unix(payload.TS, 0))
|
||||
if elapsed > 5*time.Minute || elapsed < -1*time.Minute {
|
||||
return nil, errors.New("timestamp invalid or expired")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Decode base64url
|
||||
wrappedKey, err := base64.RawURLEncoding.DecodeString(payload.WrappedKey)
|
||||
@@ -455,8 +459,11 @@ func (es EncryptedString) Value() (driver.Value, error) {
|
||||
if globalCryptoService != nil {
|
||||
encrypted, err := globalCryptoService.EncryptForStorage(string(es))
|
||||
if err != nil {
|
||||
// If encryption fails, return the original value
|
||||
return string(es), nil
|
||||
// Fail closed: never silently persist a plaintext secret when
|
||||
// encryption was expected to happen. Returning the error aborts the
|
||||
// write so a misconfigured/broken crypto service cannot leak
|
||||
// credentials into the database in cleartext.
|
||||
return nil, fmt.Errorf("failed to encrypt sensitive field for storage: %w", err)
|
||||
}
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
@@ -9,8 +9,14 @@ services:
|
||||
stop_grace_period: 30s # Allow the app 30 seconds for graceful shutdown
|
||||
ports:
|
||||
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
||||
- "6060:6060" # pprof profiling
|
||||
# pprof profiling is bound to host loopback only; uncomment for local debug.
|
||||
# - "127.0.0.1:6060:6060"
|
||||
volumes:
|
||||
# NOTE: .env is bind-mounted so the beginner-onboarding flow
|
||||
# (persistBeginnerWalletEnv) can write CLAW402_WALLET_* back to the host
|
||||
# file. Without this mount the wallet is regenerated on every container
|
||||
# restart. For threat models where the .env file should not be reachable
|
||||
# via container RCE, deploy via env vars only and remove this mount.
|
||||
- ./.env:/app/.env
|
||||
- ./data:/app/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
|
||||
44
docs/assets/nofx-banner.svg
Normal file
44
docs/assets/nofx-banner.svg
Normal file
@@ -0,0 +1,44 @@
|
||||
<svg width="1200" height="380" viewBox="0 0 1200 380" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<clipPath id="frame"><rect width="1200" height="380" rx="14"/></clipPath>
|
||||
<pattern id="dots" width="28" height="28" patternUnits="userSpaceOnUse">
|
||||
<circle cx="1.5" cy="1.5" r="1.2" fill="#1A1813" opacity="0.07"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<g clip-path="url(#frame)">
|
||||
<rect width="1200" height="380" fill="#F1ECE2"/>
|
||||
<rect width="1200" height="380" fill="url(#dots)"/>
|
||||
|
||||
<!-- title bar -->
|
||||
<rect width="1200" height="52" fill="#F7F4EC"/>
|
||||
<line x1="0" y1="52.5" x2="1200" y2="52.5" stroke="#1A1813" stroke-opacity="0.14"/>
|
||||
<circle cx="30" cy="26" r="6" fill="#D6433A" opacity="0.9"/>
|
||||
<circle cx="54" cy="26" r="6" fill="#B4B2A9"/>
|
||||
<circle cx="78" cy="26" r="6" fill="#2E8B57" opacity="0.9"/>
|
||||
<text x="600" y="31" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="13" letter-spacing="2" fill="#8A8478">nofx — ai trading terminal</text>
|
||||
|
||||
<!-- wordmark -->
|
||||
<text x="64" y="182" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="92" font-weight="700" letter-spacing="4" fill="#1A1813">NOFX<tspan fill="#E0483B">_</tspan></text>
|
||||
<text x="64" y="228" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="17" letter-spacing="9" fill="#6B6557">AI TRADING TERMINAL</text>
|
||||
|
||||
<text x="64" y="286" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="15" fill="#6B6557">Language models make the decisions.</text>
|
||||
<text x="64" y="312" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="15" fill="#6B6557">A Go runtime enforces the limits.</text>
|
||||
|
||||
<!-- divider -->
|
||||
<line x1="672" y1="84" x2="672" y2="344" stroke="#1A1813" stroke-opacity="0.12"/>
|
||||
|
||||
<!-- session log -->
|
||||
<g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="14.5">
|
||||
<text x="712" y="112" fill="#8A8478">$ nofx start</text>
|
||||
<text x="712" y="146"><tspan fill="#2E8B57">✓</tspan><tspan fill="#1A1813" dx="10">preflight passed</tspan><tspan fill="#8A8478" dx="10">7/7 checks</tspan></text>
|
||||
<text x="712" y="180"><tspan fill="#2E8B57">✓</tspan><tspan fill="#1A1813" dx="10">trader online</tspan><tspan fill="#8A8478" dx="10">deepseek · hyperliquid</tspan></text>
|
||||
<text x="712" y="222"><tspan fill="#8A8478">decision</tspan><tspan fill="#E0483B" dx="12" font-weight="700">OPEN_LONG</tspan><tspan fill="#1A1813" dx="12">BTC ×3</tspan></text>
|
||||
<text x="712" y="252"><tspan fill="#8A8478">reason</tspan><tspan fill="#6B6557" dx="12">oi rising · funding neutral · 4h breakout</tspan></text>
|
||||
<text x="712" y="282"><tspan fill="#8A8478">risk</tspan><tspan fill="#6B6557" dx="12">sl −2.5% · tp +5.0% · placed on exchange</tspan></text>
|
||||
<text x="712" y="324"><tspan fill="#8A8478">equity</tspan><tspan fill="#1A1813" dx="12">$12,847</tspan><tspan fill="#8A8478" dx="18">pnl</tspan><tspan fill="#2E8B57" dx="12">+4.2%</tspan></text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<rect x="0.5" y="0.5" width="1199" height="379" rx="14" fill="none" stroke="#1A1813" stroke-opacity="0.22"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -12,8 +12,11 @@ NOFX 文档提供多种语言版本。
|
||||
|----------|-------------|--------|-------------|
|
||||
| 🇬🇧 **English** | [README.md](../../README.md) | ✅ Complete | Core Team |
|
||||
| 🇨🇳 **Chinese (中文)** | [README.md](zh-CN/README.md) | ✅ Complete | Community |
|
||||
| 🇯🇵 **Japanese (日本語)** | [README.md](ja/README.md) | ✅ Complete | Community |
|
||||
| 🇰🇷 **Korean (한국어)** | [README.md](ko/README.md) | ✅ Complete | Community |
|
||||
| 🇷🇺 **Russian (Русский)** | [README.md](ru/README.md) | ✅ Complete | Community |
|
||||
| 🇺🇦 **Ukrainian (Українська)** | [README.md](uk/README.md) | ✅ Complete | Community |
|
||||
| 🇻🇳 **Vietnamese (Tiếng Việt)** | [README.md](vi/README.md) | ✅ Complete | Community |
|
||||
|
||||
---
|
||||
|
||||
@@ -30,6 +33,16 @@ NOFX 文档提供多种语言版本。
|
||||
- **安全政策:** [../../SECURITY.md](../../SECURITY.md#中文)
|
||||
- **常见问题:** [../guides/faq.zh-CN.md](../guides/faq.zh-CN.md)
|
||||
|
||||
### 日本語 🇯🇵
|
||||
- **メイン README:** [ja/README.md](ja/README.md)
|
||||
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
- **Security:** [../../SECURITY.md](../../SECURITY.md)
|
||||
|
||||
### 한국어 🇰🇷
|
||||
- **메인 README:** [ko/README.md](ko/README.md)
|
||||
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
- **Security:** [../../SECURITY.md](../../SECURITY.md)
|
||||
|
||||
### Русский 🇷🇺
|
||||
- **Основной README:** [ru/README.md](ru/README.md)
|
||||
- **Руководство по участию:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
@@ -40,6 +53,11 @@ NOFX 文档提供多种语言版本。
|
||||
- **Посібник із внесків:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
- **Політика безпеки:** [../../SECURITY.md](../../SECURITY.md)
|
||||
|
||||
### Tiếng Việt 🇻🇳
|
||||
- **README chính:** [vi/README.md](vi/README.md)
|
||||
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
- **Security:** [../../SECURITY.md](../../SECURITY.md)
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Help with Translations / 帮助翻译
|
||||
@@ -49,7 +67,7 @@ NOFX 文档提供多种语言版本。
|
||||
We welcome translation contributions! / 我们欢迎翻译贡献!
|
||||
|
||||
**What needs translation? / 需要翻译什么?**
|
||||
- ✅ Main README (complete for 4 languages)
|
||||
- ✅ Main README (complete for 7 languages)
|
||||
- 🚧 Deployment guides (partial)
|
||||
- 📋 User guides (needed)
|
||||
- 📋 Contributing guide (needed for RU/UK)
|
||||
@@ -94,10 +112,11 @@ faq.zh-CN.md → Chinese FAQ
|
||||
**Language Codes:**
|
||||
- `en` - English (default, no suffix needed)
|
||||
- `zh-CN` - Simplified Chinese
|
||||
- `ja` - Japanese
|
||||
- `ko` - Korean
|
||||
- `ru` - Russian
|
||||
- `uk` - Ukrainian
|
||||
- `ja` - Japanese *(future)*
|
||||
- `ko` - Korean *(future)*
|
||||
- `vi` - Vietnamese
|
||||
|
||||
### Quality Standards / 质量标准
|
||||
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<h1 align="center">NOFX</h1>
|
||||
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a> の支援を受けています</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<strong>あなた専属の AI トレーディングアシスタント。</strong><br/>
|
||||
<strong>あらゆる市場。あらゆるモデル。API キー不要、USDC で支払い。</strong>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI トレーディングターミナル" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,159 +21,262 @@
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
NOFX はオープンソースの**自律型** AI トレーディングアシスタントです。従来の AI ツールのように手動でモデルを設定し、API キーを管理し、データソースを接続する必要はありません — NOFX の AI は**市場を自ら認識し、モデルを自ら選択し、データを自ら取得します**。人間の介入はゼロ。あなたは戦略を設定するだけ、残りは AI が処理します。
|
||||
NOFX は、戦略そのものが言語モデルであるオープンソースのトレーディングターミナルです。各トレーダーは、市場構造を読み取り、判断し、執行し、その根拠を記録するという連続的なループを回し続けます。その間、Go ランタイムがすべての注文を、モデルには上書きできないハードなリスク制限内に抑え込みます。
|
||||
|
||||
**完全自律**: AI がどのモデルを使うか、どの市場データを取得するか、いつ取引するかを自ら判断します。手動のモデル設定不要。複数サービスの API キー管理不要。USDC ウォレットに入金して実行するだけ。
|
||||
|
||||
他との違い:**[x402](https://x402.org) マイクロペイメント内蔵**。API キー不要。USDC ウォレットに入金してリクエストごとに支払い。ウォレットがあなたの身分証明。
|
||||
トレーダーの構成は自由です。任意のモデル、9 つの取引所のいずれか、任意の戦略を組み合わせられます。複数のトレーダーを並行して走らせ、実現リターンに基づく公開リーダーボードで比較できます。すべては自分のマシン上で動作し、取引所の認証情報は保存時に暗号化され、外部に送信されることはありません。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
**http://127.0.0.1:3000** を開く。完了。
|
||||
ターミナルは `http://127.0.0.1:3000` で開きます。
|
||||
|
||||
---
|
||||
**初回起動**
|
||||
|
||||
## クイックデモ
|
||||
1. 登録します — 最初に作成したアカウントがこのインスタンスのオーナーになります。
|
||||
2. ガイド付きの起動手順に従います。自動作成される AI 手数料ウォレットに **$1+ USDC**(Base ネットワーク)を入金し、続いて Hyperliquid を接続して取引資金として **$12+ USDC** を入金します。
|
||||
3. **Autopilot** を開始します。AI は数分ごとに市場をスキャンして自律的に取引し、すべての判断はその場でダッシュボードに表示されます。ワンクリックでいつでも停止できます。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX クイックデモ動画" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
カバー画像をクリックするとデモ動画を視聴できます。
|
||||
</p>
|
||||
## 取引所の登録
|
||||
|
||||
---
|
||||
NOFX は無料のオープンソースソフトウェアです。以下のパートナーリンク経由で口座を開設すると、取引手数料の割引が受けられるうえ、継続的な開発の資金にもなります。
|
||||
|
||||
## x402 の仕組み
|
||||
|
||||
従来のフロー:アカウント登録 → クレジット購入 → API キー取得 → クォータ管理 → キーのローテーション。
|
||||
|
||||
x402 フロー:
|
||||
|
||||
```
|
||||
リクエスト → 402(価格提示)→ ウォレットが USDC を署名 → リトライ → 完了
|
||||
```
|
||||
|
||||
アカウント不要。API キー不要。前払いクレジット不要。ウォレット1つで全モデル。
|
||||
|
||||
### 内蔵 x402 プロバイダー
|
||||
|
||||
| プロバイダー | チェーン | モデル |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ モデル |
|
||||
|
||||
---
|
||||
|
||||
## 機能
|
||||
|
||||
| 機能 | 説明 |
|
||||
|:--------|:------------|
|
||||
| **マルチ AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — いつでも切替 |
|
||||
| **マルチ取引所** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **ストラテジースタジオ** | ビジュアルビルダー — コインソース、インジケーター、リスク管理 |
|
||||
| **AI ディベートアリーナ** | 複数 AI が取引を議論(ブル vs ベア vs アナリスト)、投票、実行 |
|
||||
| **AI 競争** | AI がリアルタイムで競争、リーダーボードで成績ランキング |
|
||||
| **Telegram エージェント** | トレーディングアシスタントとチャット — ストリーミング、ツール呼び出し、メモリ |
|
||||
| **バックテストラボ** | 過去データシミュレーション、エクイティカーブと成績指標 |
|
||||
| **ダッシュボード** | ライブポジション、損益、Chain of Thought 付き AI 判断ログ |
|
||||
|
||||
### 市場
|
||||
|
||||
暗号通貨 · 米国株 · FX · 貴金属
|
||||
|
||||
### 取引所 (CEX)
|
||||
|
||||
| 取引所 | ステータス | 登録 (手数料割引) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| 取引所 | 状態 | 手数料割引付きで登録 |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [登録](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [登録](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [登録](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### 取引所 (Perp-DEX)
|
||||
|
||||
| 取引所 | ステータス | 登録 (手数料割引) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [登録](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI モデル (API キーモード)
|
||||
<br/>
|
||||
|
||||
| AI モデル | ステータス | API キー取得 |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [API キー取得](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [API キー取得](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [API キー取得](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [API キー取得](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [API キー取得](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [API キー取得](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [API キー取得](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [API キー取得](https://platform.minimaxi.com) |
|
||||
## デモ
|
||||
|
||||
### AI モデル (x402 モード — API キー不要)
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
15+ モデルを [Claw402](https://claw402.ai) 経由で利用 — USDC ウォレットのみ
|
||||
<br/>
|
||||
|
||||
---
|
||||
## モデルは提案する。ランタイムが決める。
|
||||
|
||||
判断は、[Claw402.ai](https://claw402.ai)・Vergex のデータスタックを読み取る言語モデルから生まれます。全市場を方向バイアスとシグナル強度でランク付けするライブシグナルボード、銘柄ごとの Signal Lab ディープシグナル、群衆の「燃料」と「壁」の位置を映すコストベース・清算ヒートマップ、リアルタイムのネットフロー — これらを生のローソク足とトレーダー自身のライブ実績と突き合わせます。ただし、執行はそうではありません。
|
||||
|
||||
すべての注文は、モデルの手が届かない、コードで強制されるリスク制限を通過します。
|
||||
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| ポジション制限 | 同時ポジション数の上限、想定元本は口座資産に対する比率で制限、1 シンボルにつき 1 ポジション |
|
||||
| レバレッジ制限 | モデルの要求とは無関係に、注文サイズの算出時にハードキャップを適用 |
|
||||
| 取引所側の保護 | すべてのエントリー直後に、ストップロスとテイクプロフィットを取引所側に設置 |
|
||||
| ドローダウン自動クローズ | ピークから利益を大きく吐き出したポジションは自動的にクローズ |
|
||||
| 取引スロットリング | 最低保有時間、シンボルごとの再エントリークールダウン、サイクルごと・1 時間ごとのエントリー回数制限 |
|
||||
| セーフモード | モデルの失敗が繰り返された場合、モデルが回復するまで新規エントリーをブロック |
|
||||
| 起動前チェック | トレーダーの開始前に、モデルアクセス、ウォレット資金、戦略、取引所残高を検証 |
|
||||
|
||||
各判断は、モデルの推論全文とともに保存されます。記録の残らないポジションは存在しません。
|
||||
|
||||
<br/>
|
||||
|
||||
## ターミナル
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Autopilot** | ガイド付きの起動フロー:資金投入、接続、入金、開始 — 全工程をサーバー側の事前チェックが支えます |
|
||||
| **Strategy Studio** | スタイルプリセット、コインユニバース、インジケーター、レバレッジ、エントリー確信度、カスタムプロンプト |
|
||||
| **Competition** | 実現リターンでランク付けされる公開リーダーボード。各エントリーには使用モデルが明記されます |
|
||||
| **Dashboard** | ライブのポジション、注文、統計、そしてすべての判断の背後にある推論 |
|
||||
|
||||
<details>
|
||||
<summary>スクリーンショット</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| 概要 | マーケットチャート |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| 取引統計 | ポジション履歴 |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| 戦略エディタ | インジケーター設定 |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
| コンペティション | 設定 |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## モデル
|
||||
|
||||
自分の API キーで 8 つのプロバイダーを利用できます — DeepSeek、OpenAI、Claude、Qwen、Gemini、Grok、Kimi、MiniMax。カスタムエンドポイントとカスタムモデル名にも対応しています。
|
||||
|
||||
あるいは、キーを一切使わない方法もあります。[Claw402](https://claw402.ai) は x402 プロトコル上で、モデルの利用量を呼び出しごとに USDC で課金します。Base 上のウォレットひとつが、すべての API キーの代わりになります。
|
||||
|
||||
| プロバイダー | アクセス |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [公式割引付きの従量課金 AI モデル](https://claw402.ai) |
|
||||
|
||||
## マーケット
|
||||
|
||||
9 つの取引所すべてで暗号資産の無期限先物を取引できます。Hyperliquid では、同じランタイムが暗号資産に加えて、トークン化された米国株、コモディティ、株価指数、FX、プレ IPO 無期限先物 — TSLA、NVDA、GOLD、SPX、EUR、OPENAI — も取引します。
|
||||
|
||||
<br/>
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
<br/>
|
||||
|
||||
## インストール
|
||||
|
||||
### Linux / macOS
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (クラウド)
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### ソースから
|
||||
**Windows** — [Docker Desktop](https://www.docker.com/products/docker-desktop/) をインストールしてから、次を実行します。
|
||||
|
||||
```bash
|
||||
# 前提条件: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # バックエンド
|
||||
cd web && npm install && npm run dev # フロントエンド(新しいターミナル)
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
**ソースからビルド** — Go 1.21+、Node.js 18+ が必要です。
|
||||
|
||||
## リンク
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
**アップデート** — インストールスクリプトを再実行すると、その場でアップグレードされます。
|
||||
|
||||
<details>
|
||||
<summary>サーバーへのデプロイ</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**Cloudflare 経由の HTTPS**
|
||||
|
||||
1. [Cloudflare](https://dash.cloudflare.com)(無料プラン)にドメインを追加
|
||||
2. A レコード → サーバー IP(Proxied を有効化)
|
||||
3. SSL/TLS → Flexible
|
||||
4. `.env` に `TRANSPORT_ENCRYPTION=true` を設定
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## ドキュメント
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| ウェブサイト | [nofxai.com](https://nofxai.com) |
|
||||
| ダッシュボード | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API ドキュメント | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [はじめに](../../getting-started/README.md) | デプロイと取引所 API のガイド |
|
||||
| [アーキテクチャ](../../architecture/README.md) | システム設計とモジュール索引 |
|
||||
| [戦略モジュール](../../architecture/STRATEGY_MODULE.md) | 銘柄選択、AI プロンプト、執行 |
|
||||
| [FAQ](../../guides/faq.en.md) | よくある質問 |
|
||||
| [トラブルシューティング](../../guides/TROUBLESHOOTING.md) | よくある問題の診断 |
|
||||
|
||||
> **リスク警告**: AI 自動取引には重大なリスクがあります。学習/研究目的または少額でのテストのみを推奨します。
|
||||
## コミュニティ
|
||||
|
||||
---
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [ライブダッシュボード](https://vergex.trade/explore)
|
||||
|
||||
## License
|
||||
## コントリビューション
|
||||
|
||||
コード、ドキュメント、翻訳、バグ報告のいずれも歓迎します。詳細は[貢献ガイド](../../../CONTRIBUTING.md)、[行動規範](../../../CODE_OF_CONDUCT.md)、[セキュリティポリシー](../../../SECURITY.md)をご覧ください。
|
||||
|
||||
NOFX は有意義な貢献を記録しており、エコシステムの成長に応じて貢献者に還元していく予定です。優先度の高い Issue にはより高いウェイトが付きます。
|
||||
|
||||
| 貢献の種類 | ウェイト |
|
||||
| :---------------- | :----: |
|
||||
| ピン留め Issue の PR | ★★★★★★ |
|
||||
| コード(マージ済み PR) | ★★★★★ |
|
||||
| バグ修正 | ★★★★ |
|
||||
| 機能アイデア | ★★★ |
|
||||
| バグ報告 | ★★ |
|
||||
| ドキュメント | ★★ |
|
||||
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## スポンサー
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
[スポンサーになる](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
<br/>
|
||||
|
||||
NOFX が役に立ったら、スターを付けていただけると、他のトレーダーがこのプロジェクトを見つけやすくなります。
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## ライセンス
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>自動売買には大きなリスクが伴います。AI 駆動の戦略は実験的なものであり、損失を出す可能性があります。適切なポジションサイズを守り、各取引所の仕組みを理解し、失っても差し支えのない資金以外では決して取引しないでください。詳細は[免責事項](../../../DISCLAIMER.md)をご覧ください。</sub>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<h1 align="center">NOFX</h1>
|
||||
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a>가 지원합니다</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<strong>당신만의 AI 트레이딩 어시스턴트.</strong><br/>
|
||||
<strong>모든 시장. 모든 모델. API 키 없이 USDC로 결제.</strong>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,159 +21,262 @@
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
NOFX는 오픈소스 **자율형** AI 트레이딩 어시스턴트입니다. 수동으로 모델을 설정하고, API 키를 관리하고, 데이터 소스를 연결해야 하는 기존 AI 도구와 달리 — NOFX의 AI는 **시장을 스스로 인식하고, 모델을 스스로 선택하고, 데이터를 스스로 가져옵니다**. 인간 개입 제로. 전략만 설정하면 나머지는 AI가 처리합니다.
|
||||
NOFX는 언어 모델이 곧 전략이 되는 오픈소스 트레이딩 터미널입니다. 각 트레이더는 시장 구조 읽기, 판단, 실행, 근거 기록으로 이어지는 루프를 끊임없이 반복하고, Go 런타임은 모든 주문을 모델이 절대 우회할 수 없는 하드 리스크 한도 안에 묶어 둡니다.
|
||||
|
||||
**완전 자율**: AI가 어떤 모델을 사용할지, 어떤 시장 데이터를 가져올지, 언제 거래할지를 스스로 결정합니다. 수동 모델 설정 불필요. 여러 서비스의 API 키 관리 불필요. USDC 지갑에 충전하고 실행하기만 하면 됩니다.
|
||||
|
||||
차별점: **[x402](https://x402.org) 마이크로 결제 내장**. API 키 불필요. USDC 지갑에 충전하고 요청마다 결제. 지갑이 곧 신원.
|
||||
트레이더는 자유롭게 조합할 수 있습니다. 어떤 모델이든, 아홉 개 거래소 중 어디든, 어떤 전략이든 가능합니다. 여러 트레이더를 나란히 돌리면서 실현 수익률 기준의 공개 리더보드에서 비교해 보세요. 모든 것은 사용자의 머신에서 실행되며, 거래소 자격 증명은 저장 시 암호화되고 절대 외부로 나가지 않습니다.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
**http://127.0.0.1:3000** 을 열면 완료.
|
||||
터미널은 `http://127.0.0.1:3000`에서 열립니다.
|
||||
|
||||
---
|
||||
**첫 실행**
|
||||
|
||||
## 빠른 데모
|
||||
1. 계정을 등록합니다 — 첫 번째 계정이 해당 인스턴스의 소유자가 됩니다.
|
||||
2. 가이드 런치를 따라갑니다. 자동으로 생성된 AI 수수료 지갑에 **$1+ USDC**(Base 네트워크)를 넣은 뒤, Hyperliquid를 연결하고 거래 자금으로 **$12+ USDC**를 입금합니다.
|
||||
3. **Autopilot**을 시작합니다. AI가 몇 분마다 시장을 스캔하며 스스로 거래하고, 모든 결정은 발생하는 즉시 대시보드에 표시됩니다. 언제든 클릭 한 번으로 중지할 수 있습니다.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX 빠른 데모 영상" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
커버 이미지를 클릭하면 데모 영상을 볼 수 있습니다.
|
||||
</p>
|
||||
## 거래소 등록
|
||||
|
||||
---
|
||||
NOFX는 무료 오픈소스입니다. 아래 파트너 링크로 계정을 개설하면 거래 수수료가 할인되며, 프로젝트의 지속적인 개발에도 보탬이 됩니다.
|
||||
|
||||
## x402 작동 방식
|
||||
|
||||
기존 플로우: 계정 등록 → 크레딧 구매 → API 키 받기 → 쿼터 관리 → 키 교체.
|
||||
|
||||
x402 플로우:
|
||||
|
||||
```
|
||||
요청 → 402 (가격 제시) → 지갑이 USDC 서명 → 재시도 → 완료
|
||||
```
|
||||
|
||||
계정 불필요. API 키 불필요. 선불 크레딧 불필요. 지갑 하나로 모든 모델.
|
||||
|
||||
### 내장 x402 프로바이더
|
||||
|
||||
| 프로바이더 | 체인 | 모델 |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ 모델 |
|
||||
|
||||
---
|
||||
|
||||
## 기능
|
||||
|
||||
| 기능 | 설명 |
|
||||
|:--------|:------------|
|
||||
| **멀티 AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — 언제든 전환 |
|
||||
| **멀티 거래소** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **전략 스튜디오** | 비주얼 빌더 — 코인 소스, 지표, 리스크 관리 |
|
||||
| **AI 토론 아레나** | 여러 AI가 거래 토론 (강세 vs 약세 vs 분석가), 투표, 실행 |
|
||||
| **AI 경쟁** | AI가 실시간 경쟁, 리더보드 순위 |
|
||||
| **Telegram 에이전트** | 트레이딩 어시스턴트와 채팅 — 스트리밍, 도구 호출, 메모리 |
|
||||
| **백테스트 랩** | 과거 시뮬레이션, 자산 곡선 및 성과 지표 |
|
||||
| **대시보드** | 실시간 포지션, 손익, Chain of Thought AI 결정 로그 |
|
||||
|
||||
### 시장
|
||||
|
||||
암호화폐 · 미국 주식 · 외환 · 귀금속
|
||||
|
||||
### 거래소 (CEX)
|
||||
|
||||
| 거래소 | 상태 | 등록 (수수료 할인) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| 거래소 | 상태 | 수수료 할인 등록 |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [등록](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [등록](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [등록](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [등록](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [등록](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### 거래소 (Perp-DEX)
|
||||
|
||||
| 거래소 | 상태 | 등록 (수수료 할인) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [등록](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI 모델 (API 키 모드)
|
||||
<br/>
|
||||
|
||||
| AI 모델 | 상태 | API 키 받기 |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [API 키 받기](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [API 키 받기](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [API 키 받기](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [API 키 받기](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [API 키 받기](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [API 키 받기](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [API 키 받기](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [API 키 받기](https://platform.minimaxi.com) |
|
||||
## 데모
|
||||
|
||||
### AI 모델 (x402 모드 — API 키 불필요)
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
15+ 모델을 [Claw402](https://claw402.ai)로 이용 — USDC 지갑만 있으면 됩니다
|
||||
<br/>
|
||||
|
||||
---
|
||||
## 모델은 제안하고, 런타임이 결정합니다
|
||||
|
||||
의사결정은 [Claw402.ai](https://claw402.ai) · Vergex 데이터 스택을 읽는 언어 모델에서 나옵니다. 모든 시장을 방향 편향과 시그널 강도로 순위 매기는 실시간 시그널 보드, 종목별 Signal Lab 딥 시그널, 시장의 '연료'와 '벽'이 어디에 있는지 보여주는 코스트·청산 히트맵, 실시간 순유입 — 이를 원시 캔들과 트레이더 자신의 실시간 트랙 레코드로 교차 검증합니다. 그러나 실행은 그렇지 않습니다.
|
||||
|
||||
모든 주문은 모델의 손이 닿지 않는 곳에서 코드로 강제되는 한도를 통과해야 합니다.
|
||||
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| 포지션 한도 | 최대 동시 포지션 수, 자본 대비 비율로 상한이 걸린 명목 금액, 심볼당 포지션 1개 |
|
||||
| 레버리지 클램프 | 모델이 무엇을 요청하든 관계없이 주문 크기 산정 시점에 적용되는 하드 캡 |
|
||||
| 거래소 측 보호 | 모든 진입 직후 거래소에 스탑로스와 테이크프로핏을 즉시 배치 |
|
||||
| 드로다운 자동 청산 | 고점 대비 이익을 과도하게 반납한 수익 포지션은 자동으로 청산 |
|
||||
| 거래 스로틀링 | 최소 보유 시간, 심볼별 재진입 쿨다운, 사이클당·시간당 진입 횟수 제한 |
|
||||
| 세이프 모드 | 모델 오류가 반복되면 모델이 복구될 때까지 신규 진입을 차단 |
|
||||
| 런치 프리플라이트 | 트레이더 시작 전에 모델 접근성, 지갑 자금, 전략, 거래소 잔고를 검증 |
|
||||
|
||||
각 결정은 모델의 전체 추론 과정과 함께 저장됩니다. 기록 없는 포지션은 존재하지 않습니다.
|
||||
|
||||
<br/>
|
||||
|
||||
## 터미널
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Autopilot** | 가이드 런치: 자금 입금, 연결, 예치, 시작 — 전 과정에 서버 측 프리플라이트 적용 |
|
||||
| **Strategy Studio** | 스타일 프리셋, 코인 유니버스, 지표, 레버리지, 진입 신뢰도, 커스텀 프롬프트 |
|
||||
| **경쟁** | 실현 수익률로 순위가 매겨지는 공개 리더보드, 각 항목마다 사용한 모델 표기 |
|
||||
| **대시보드** | 실시간 포지션, 주문, 통계, 그리고 모든 결정 뒤에 있는 추론 과정 |
|
||||
|
||||
<details>
|
||||
<summary>스크린샷</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| 개요 | 마켓 차트 |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| 거래 통계 | 포지션 기록 |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| 전략 에디터 | 지표 설정 |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
| 경쟁 | 설정 |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## 모델
|
||||
|
||||
자신의 키로 사용할 수 있는 여덟 개 제공업체 — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — 커스텀 엔드포인트와 모델 이름도 지원합니다.
|
||||
|
||||
키가 전혀 없어도 됩니다. [Claw402](https://claw402.ai)는 x402 프로토콜을 통해 모델 사용량을 호출 단위로 USDC로 정산합니다. Base 네트워크의 지갑 하나가 모든 API 키를 대신합니다.
|
||||
|
||||
| 제공업체 | 이용 방법 |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [공식 할인이 적용된 종량제 AI 모델](https://claw402.ai) |
|
||||
|
||||
## 시장
|
||||
|
||||
아홉 개 거래소 전체에서 암호화폐 무기한 선물을 거래합니다. Hyperliquid에서는 같은 런타임이 암호화폐와 함께 토큰화된 미국 주식, 원자재, 지수, 외환, 프리 IPO 무기한 선물 — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — 도 거래합니다.
|
||||
|
||||
<br/>
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
<br/>
|
||||
|
||||
## 설치
|
||||
|
||||
### Linux / macOS
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (클라우드)
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 소스에서
|
||||
**Windows** — [Docker Desktop](https://www.docker.com/products/docker-desktop/)을 설치한 뒤:
|
||||
|
||||
```bash
|
||||
# 필수 조건: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # 백엔드
|
||||
cd web && npm install && npm run dev # 프론트엔드 (새 터미널)
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
**소스에서 빌드** — Go 1.21+, Node.js 18+:
|
||||
|
||||
## 링크
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
**업데이트** — 설치 스크립트를 다시 실행하면 제자리에서 업그레이드됩니다.
|
||||
|
||||
<details>
|
||||
<summary>서버 배포</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**Cloudflare를 통한 HTTPS**
|
||||
|
||||
1. [Cloudflare](https://dash.cloudflare.com)에 도메인 추가(무료 플랜)
|
||||
2. A 레코드 → 서버 IP, Proxied 설정
|
||||
3. SSL/TLS → Flexible
|
||||
4. `.env`에 `TRANSPORT_ENCRYPTION=true` 설정
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## 문서
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| 웹사이트 | [nofxai.com](https://nofxai.com) |
|
||||
| 대시보드 | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API 문서 | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [시작하기](../../getting-started/README.md) | 배포 및 거래소 API 가이드 |
|
||||
| [아키텍처](../../architecture/README.md) | 시스템 설계와 모듈 색인 |
|
||||
| [전략 모듈](../../architecture/STRATEGY_MODULE.md) | 코인 선정, AI 프롬프트, 실행 |
|
||||
| [FAQ](../../guides/faq.en.md) | 자주 묻는 질문 |
|
||||
| [문제 해결](../../guides/TROUBLESHOOTING.md) | 흔한 문제 진단하기 |
|
||||
|
||||
> **위험 경고**: AI 자동 거래에는 상당한 위험이 있습니다. 학습/연구 또는 소액 테스트만 권장합니다.
|
||||
## 커뮤니티
|
||||
|
||||
---
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [라이브 대시보드](https://vergex.trade/explore)
|
||||
|
||||
## License
|
||||
## 기여
|
||||
|
||||
코드, 문서, 번역, 버그 리포트 모두 환영합니다 — [기여 가이드](../../../CONTRIBUTING.md), [행동 강령](../../../CODE_OF_CONDUCT.md), [보안 정책](../../../SECURITY.md)을 참고하세요.
|
||||
|
||||
NOFX는 의미 있는 기여를 기록하며, 생태계가 성장함에 따라 기여자에게 보상할 계획입니다. 우선순위 이슈에는 더 높은 가중치가 부여됩니다.
|
||||
|
||||
| 기여 유형 | 가중치 |
|
||||
| :---------------- | :----: |
|
||||
| 고정 이슈 PR | ★★★★★★ |
|
||||
| 코드(머지된 PR) | ★★★★★ |
|
||||
| 버그 수정 | ★★★★ |
|
||||
| 기능 아이디어 | ★★★ |
|
||||
| 버그 리포트 | ★★ |
|
||||
| 문서 | ★★ |
|
||||
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## 스폰서
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
[스폰서 되기](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
<br/>
|
||||
|
||||
NOFX가 도움이 되었다면, 스타 하나가 다른 트레이더들이 이 프로젝트를 발견하는 데 큰 힘이 됩니다.
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## 라이선스
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>자동매매에는 상당한 위험이 따릅니다. AI 기반 전략은 실험적이며 손실이 발생할 수 있습니다. 포지션 규모를 적절히 관리하고, 각 거래소의 구조를 이해하며, 잃어도 감당할 수 있는 자금으로만 거래하세요. 전체 [면책 조항](../../../DISCLAIMER.md)을 확인하세요.</sub>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<h1 align="center">NOFX</h1>
|
||||
<p align="center"><strong>При поддержке <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Ваш персональный AI торговый ассистент.</strong><br/>
|
||||
<strong>Любой рынок. Любая модель. Оплата USDC, без API ключей.</strong>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,160 +21,262 @@
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
NOFX — это **автономный** AI торговый ассистент с открытым исходным кодом. В отличие от традиционных AI инструментов, где нужно вручную настраивать модели, управлять API ключами и подключать источники данных — AI в NOFX **сам анализирует рынки, выбирает модели и получает данные**. Нулевое вмешательство человека. Вы задаёте стратегию, AI делает всё остальное.
|
||||
NOFX — торговый терминал с открытым исходным кодом, где стратегией выступает языковая модель. Каждый трейдер работает в непрерывном цикле — читает структуру рынка, принимает решение, исполняет его и записывает ход рассуждений, — а рантайм на Go прижимает каждый ордер к жёстким риск-лимитам, которые модель не может обойти.
|
||||
|
||||
**Полная автономность**: AI сам решает, какую модель использовать, какие рыночные данные получить, когда торговать. Без ручной настройки моделей. Без жонглирования API ключами разных сервисов. Просто пополните USDC кошелёк и запустите.
|
||||
|
||||
Ключевое отличие: **встроенные [x402](https://x402.org) микроплатежи**. Без API ключей. Пополните USDC кошелёк и платите за каждый запрос. Кошелёк — это ваша идентификация.
|
||||
Трейдеры собираются свободно: любая модель, любая из девяти бирж, любая стратегия. Запускайте несколько параллельно и сравнивайте их в публичной таблице лидеров по реализованной доходности. Всё работает на вашей собственной машине; биржевые ключи шифруются при хранении и никогда её не покидают.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Откройте **http://127.0.0.1:3000**. Готово.
|
||||
Терминал откроется по адресу `http://127.0.0.1:3000`.
|
||||
|
||||
---
|
||||
**Первый запуск**
|
||||
|
||||
## Быстрое демо
|
||||
1. Зарегистрируйтесь — первый аккаунт становится владельцем экземпляра.
|
||||
2. Пройдите пошаговый запуск: положите **$1+ USDC** (сеть Base) в кошелёк для оплаты AI, который он создаст для вас, затем подключите Hyperliquid и внесите **$12+ USDC** для торговли.
|
||||
3. Запустите **Autopilot**. AI сканирует рынок каждые несколько минут и торгует самостоятельно; каждое решение появляется на дашборде в момент принятия. Остановить можно в любой момент одним кликом.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="Видео быстрого демо NOFX" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
Нажмите на изображение обложки, чтобы посмотреть демо-видео.
|
||||
</p>
|
||||
## Регистрация на биржах
|
||||
|
||||
---
|
||||
NOFX бесплатен и открыт. Открытие счёта по партнёрским ссылкам ниже даёт сниженные торговые комиссии и поддерживает дальнейшую разработку.
|
||||
|
||||
## Как работает x402
|
||||
| Биржа | Статус | Регистрация со скидкой на комиссии |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Зарегистрироваться](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Зарегистрироваться](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Зарегистрироваться](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Зарегистрироваться](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Зарегистрироваться](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Зарегистрироваться](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Зарегистрироваться](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Зарегистрироваться](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Зарегистрироваться](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
Традиционный процесс: регистрация → покупка кредитов → получение API ключа → управление квотой → ротация ключей.
|
||||
<br/>
|
||||
|
||||
x402 процесс:
|
||||
## Демо
|
||||
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
<br/>
|
||||
|
||||
## Модель предполагает. Рантайм располагает.
|
||||
|
||||
Решения принимает языковая модель, читающая стек данных [Claw402.ai](https://claw402.ai) · Vergex: живой сигнальный борд, ранжирующий каждый рынок по направлению и силе сигнала, глубокие сигналы Signal Lab по каждому инструменту, тепловые карты себестоимости и ликвидаций, показывающие, где у толпы «топливо» и «стены», и чистый поток средств в реальном времени — всё это сверяется с сырыми свечами и собственным живым трек-рекордом трейдера. Исполнение — нет.
|
||||
|
||||
Каждый ордер проходит через лимиты, зашитые в код и недоступные модели:
|
||||
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| Лимиты позиций | Максимум одновременных позиций, номинал ограничен долей от капитала, одна позиция на инструмент |
|
||||
| Ограничение плеча | Жёсткие пределы применяются при расчёте размера ордера, независимо от того, что запросила модель |
|
||||
| Защита на стороне биржи | Стоп-лосс и тейк-профит выставляются на бирже сразу после каждого входа |
|
||||
| Автозакрытие по просадке | Прибыльные позиции, отдавшие слишком много от своего пика, закрываются |
|
||||
| Троттлинг сделок | Минимальное время удержания, кулдауны повторного входа по инструменту, лимиты входов на цикл и на час |
|
||||
| Безопасный режим | Повторяющиеся сбои модели блокируют новые входы, пока модель не восстановится |
|
||||
| Предстартовая проверка | Доступ к модели, средства в кошельке, стратегия и балансы бирж проверяются до того, как трейдер сможет стартовать |
|
||||
|
||||
Каждое решение сохраняется вместе с полным ходом рассуждений модели. Ни одной позиции без документального следа.
|
||||
|
||||
<br/>
|
||||
|
||||
## Терминал
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Autopilot** | Пошаговый запуск: пополнить, подключить, внести депозит, стартовать — с серверной предстартовой проверкой на каждом шаге |
|
||||
| **Strategy Studio** | Пресеты стилей, наборы монет, индикаторы, плечо, порог уверенности для входа, собственные промпты |
|
||||
| **Competition** | Публичная таблица лидеров по реализованной доходности, каждая запись привязана к своей модели |
|
||||
| **Dashboard** | Живые позиции, ордера, статистика и обоснование каждого решения |
|
||||
|
||||
<details>
|
||||
<summary>Скриншоты</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Обзор | График рынка |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| Статистика торговли | История позиций |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| Редактор стратегий | Настройка индикаторов |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
| Соревнование | Конфигурация |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## Модели
|
||||
|
||||
Восемь провайдеров с вашими собственными ключами — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — включая пользовательские эндпоинты и имена моделей.
|
||||
|
||||
Или вовсе без ключей: [Claw402](https://claw402.ai) тарифицирует использование моделей за каждый вызов в USDC по протоколу x402. Кошелёк в сети Base заменяет все API-ключи.
|
||||
|
||||
| Провайдер | Доступ |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [AI-модели с оплатой по мере использования и официальной скидкой](https://claw402.ai) |
|
||||
|
||||
## Рынки
|
||||
|
||||
Криптовалютные бессрочные контракты на всех девяти биржах. На Hyperliquid тот же рантайм торгует также токенизированными акциями США, сырьевыми товарами, индексами, валютными парами и pre-IPO перпетуалами — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — наряду с криптовалютой.
|
||||
|
||||
<br/>
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
Запрос → 402 (вот цена) → кошелёк подписывает USDC → повтор → готово
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Без аккаунтов. Без API ключей. Без предоплаты. Один кошелёк, все модели.
|
||||
|
||||
### Встроенные x402 провайдеры
|
||||
|
||||
| Провайдер | Сеть | Модели |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ моделей |
|
||||
|
||||
---
|
||||
|
||||
## Возможности
|
||||
|
||||
| Функция | Описание |
|
||||
|:--------|:------------|
|
||||
| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — переключение в любой момент |
|
||||
| **Мульти-биржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Студия стратегий** | Визуальный конструктор — источники монет, индикаторы, контроль рисков |
|
||||
| **AI Арена дебатов** | Несколько AI обсуждают сделки (Бык vs Медведь vs Аналитик), голосуют, исполняют |
|
||||
| **AI Соревнование** | AI соревнуются в реальном времени, рейтинг в таблице лидеров |
|
||||
| **Telegram Агент** | Чат с торговым ассистентом — стриминг, вызов инструментов, память |
|
||||
| **Лаборатория бэктеста** | Историческая симуляция с кривой капитала и метриками |
|
||||
| **Панель управления** | Позиции в реальном времени, P/L, логи AI решений с Chain of Thought |
|
||||
|
||||
### Рынки
|
||||
|
||||
Криптовалюта · Акции США · Форекс · Металлы
|
||||
|
||||
### Биржи (CEX)
|
||||
|
||||
| Биржа | Статус | Регистрация (скидка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Регистрация](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Регистрация](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Регистрация](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Регистрация](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Регистрация](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Регистрация](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Биржи (Perp-DEX)
|
||||
|
||||
| Биржа | Статус | Регистрация (скидка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Регистрация](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Регистрация](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Регистрация](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI Модели (Режим API ключей)
|
||||
|
||||
| AI Модель | Статус | Получить API ключ |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Получить](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Получить](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Получить](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Получить](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Получить](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Получить](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Получить](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Получить](https://platform.minimaxi.com) |
|
||||
|
||||
### AI Модели (Режим x402 — без API ключей)
|
||||
|
||||
15+ моделей через [Claw402](https://claw402.ai) — только USDC кошелёк
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Установка
|
||||
|
||||
### Linux / macOS
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (Облако)
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Из исходников
|
||||
**Windows** — установите [Docker Desktop](https://www.docker.com/products/docker-desktop/), затем:
|
||||
|
||||
```bash
|
||||
# Требования: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # бэкенд
|
||||
cd web && npm install && npm run dev # фронтенд (новый терминал)
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
**Из исходников** — Go 1.21+, Node.js 18+:
|
||||
|
||||
## Ссылки
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
**Обновление** — повторно запустите скрипт установки; он обновит всё на месте.
|
||||
|
||||
<details>
|
||||
<summary>Развёртывание на сервере</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**HTTPS через Cloudflare**
|
||||
|
||||
1. Добавьте домен в [Cloudflare](https://dash.cloudflare.com) (бесплатный план)
|
||||
2. A-запись → IP сервера, с проксированием
|
||||
3. SSL/TLS → Flexible
|
||||
4. `TRANSPORT_ENCRYPTION=true` в `.env`
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## Документация
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| Сайт | [nofxai.com](https://nofxai.com) |
|
||||
| Панель | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API Документация | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [Начало работы](../../getting-started/README.md) | Гайды по развёртыванию и биржевым API |
|
||||
| [Архитектура](../../architecture/README.md) | Устройство системы и индекс модулей |
|
||||
| [Модуль стратегий](../../architecture/STRATEGY_MODULE.md) | Выбор монет, AI-промпты, исполнение |
|
||||
| [FAQ](../../guides/faq.en.md) | Частые вопросы |
|
||||
| [Устранение неполадок](../../guides/TROUBLESHOOTING.md) | Диагностика типичных проблем |
|
||||
|
||||
> **Предупреждение**: AI автоторговля несёт значительные риски. Рекомендуется только для обучения/исследований или тестирования малых сумм.
|
||||
## Сообщество
|
||||
|
||||
---
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [Живой дашборд](https://vergex.trade/explore)
|
||||
|
||||
## Участие в разработке
|
||||
|
||||
Код, документация, переводы и сообщения об ошибках — всё это приветствуется; см. [руководство для контрибьюторов](../../../CONTRIBUTING.md), [кодекс поведения](../../../CODE_OF_CONDUCT.md) и [политику безопасности](../../../SECURITY.md).
|
||||
|
||||
NOFX отслеживает значимые вклады и намерен вознаграждать контрибьюторов по мере роста экосистемы. Приоритетные issues имеют больший вес.
|
||||
|
||||
| Вклад | Вес |
|
||||
| :---------------- | :----: |
|
||||
| PR по закреплённым issues | ★★★★★★ |
|
||||
| Код (принятые PR) | ★★★★★ |
|
||||
| Исправления ошибок | ★★★★ |
|
||||
| Идеи новых функций | ★★★ |
|
||||
| Сообщения об ошибках | ★★ |
|
||||
| Документация | ★★ |
|
||||
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## Спонсоры
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
[Стать спонсором](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
<br/>
|
||||
|
||||
Если NOFX вам полезен, звезда помогает другим трейдерам его найти.
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## Лицензия
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>Автоматическая торговля сопряжена со значительным риском. Стратегии на основе AI экспериментальны и могут терять деньги. Разумно выбирайте размер позиций, разбирайтесь в устройстве каждой площадки и никогда не торгуйте средствами, потерю которых не можете себе позволить. Полный [дисклеймер](../../../DISCLAIMER.md).</sub>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<h1 align="center">NOFX</h1>
|
||||
<p align="center"><strong>За підтримки <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Ваш персональний AI торговий асистент.</strong><br/>
|
||||
<strong>Будь-який ринок. Будь-яка модель. Оплата USDC, без API ключів.</strong>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,160 +21,262 @@
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
NOFX — це **автономний** AI торговий асистент з відкритим кодом. На відміну від традиційних AI інструментів, де потрібно вручну налаштовувати моделі, керувати API ключами та підключати джерела даних — AI у NOFX **сам аналізує ринки, обирає моделі та отримує дані**. Нульове втручання людини. Ви задаєте стратегію, AI робить все інше.
|
||||
NOFX — торговий термінал з відкритим кодом, у якому стратегією є мовна модель. Кожен трейдер працює в безперервному циклі — читає структуру ринку, приймає рішення, виконує його, фіксує хід міркувань — а рантайм на Go обмежує кожен ордер жорсткими ризик-лімітами, які модель не може обійти.
|
||||
|
||||
**Повна автономність**: AI сам вирішує, яку модель використовувати, які ринкові дані отримати, коли торгувати. Без ручного налаштування моделей. Без жонглювання API ключами різних сервісів. Просто поповніть USDC гаманець і запустіть.
|
||||
|
||||
Ключова відмінність: **вбудовані [x402](https://x402.org) мікроплатежі**. Без API ключів. Поповніть USDC гаманець і платіть за кожен запит. Гаманець — це ваша ідентифікація.
|
||||
Трейдери компонуються вільно: будь-яка модель, будь-яка з дев'яти бірж, будь-яка стратегія. Запускайте кілька одночасно та порівнюйте їх у публічній таблиці лідерів за реалізованою дохідністю. Усе працює на вашій власній машині; облікові дані бірж зашифровані на диску й ніколи її не покидають.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Відкрийте **http://127.0.0.1:3000**. Готово.
|
||||
Термінал відкривається за адресою `http://127.0.0.1:3000`.
|
||||
|
||||
---
|
||||
**Перший запуск**
|
||||
|
||||
## Швидке демо
|
||||
1. Зареєструйтеся — перший акаунт стає власником інстансу.
|
||||
2. Пройдіть покроковий запуск: покладіть **$1+ USDC** (мережа Base) у гаманець для оплати AI, який він створить для вас, потім підключіть Hyperliquid і внесіть **$12+ USDC** для торгівлі.
|
||||
3. Запустіть **Autopilot**. AI сканує ринок кожні кілька хвилин і торгує самостійно; кожне рішення з'являється на дашборді в момент ухвалення. Зупинити його можна будь-коли одним кліком.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="Відео швидкого демо NOFX" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
Натисніть на зображення обкладинки, щоб переглянути демо-відео.
|
||||
</p>
|
||||
## Реєстрація на біржах
|
||||
|
||||
---
|
||||
NOFX безкоштовний і має відкритий код. Відкриття акаунта за партнерськими посиланнями нижче дає знижку на торгові комісії та фінансує подальшу розробку.
|
||||
|
||||
## Як працює x402
|
||||
|
||||
Традиційний процес: реєстрація → купівля кредитів → отримання API ключа → управління квотою → ротація ключів.
|
||||
|
||||
x402 процес:
|
||||
|
||||
```
|
||||
Запит → 402 (ось ціна) → гаманець підписує USDC → повтор → готово
|
||||
```
|
||||
|
||||
Без акаунтів. Без API ключів. Без передоплати. Один гаманець, усі моделі.
|
||||
|
||||
### Вбудовані x402 провайдери
|
||||
|
||||
| Провайдер | Мережа | Моделі |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ моделей |
|
||||
|
||||
---
|
||||
|
||||
## Можливості
|
||||
|
||||
| Функція | Опис |
|
||||
|:--------|:------------|
|
||||
| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — перемикання будь-коли |
|
||||
| **Мульти-біржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Студія стратегій** | Візуальний конструктор — джерела монет, індикатори, контроль ризиків |
|
||||
| **AI Арена дебатів** | Кілька AI обговорюють угоди, голосують, виконують |
|
||||
| **AI Змагання** | AI змагаються в реальному часі, рейтинг у таблиці лідерів |
|
||||
| **Telegram Агент** | Чат з торговим асистентом — стрімінг, виклик інструментів, пам'ять |
|
||||
| **Лабораторія бектесту** | Історична симуляція з кривою капіталу та метриками |
|
||||
| **Панель управління** | Позиції в реальному часі, P/L, логи AI рішень з Chain of Thought |
|
||||
|
||||
### Ринки
|
||||
|
||||
Криптовалюта · Акції США · Форекс · Метали
|
||||
|
||||
### Біржі (CEX)
|
||||
|
||||
| Біржа | Статус | Реєстрація (знижка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| Біржа | Статус | Реєстрація зі знижкою на комісії |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Реєстрація](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Реєстрація](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Реєстрація](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Реєстрація](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Реєстрація](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Реєстрація](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Біржі (Perp-DEX)
|
||||
|
||||
| Біржа | Статус | Реєстрація (знижка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Реєстрація](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Реєстрація](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Реєстрація](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Реєстрація](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI Моделі (Режим API ключів)
|
||||
<br/>
|
||||
|
||||
| AI Модель | Статус | Отримати API ключ |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Отримати](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Отримати](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Отримати](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Отримати](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Отримати](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Отримати](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Отримати](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Отримати](https://platform.minimaxi.com) |
|
||||
## Демо
|
||||
|
||||
### AI Моделі (Режим x402 — без API ключів)
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
15+ моделей через [Claw402](https://claw402.ai) — лише USDC гаманець
|
||||
<br/>
|
||||
|
||||
---
|
||||
## Модель пропонує. Рантайм вирішує.
|
||||
|
||||
Рішення приймає мовна модель, що читає стек даних [Claw402.ai](https://claw402.ai) · Vergex: живу сигнальну панель, яка ранжує кожен ринок за напрямком і силою сигналу, глибокі сигнали Signal Lab по кожному інструменту, теплові карти собівартості та ліквідацій, що показують, де в натовпу «паливо» і «стіни», та чистий потік коштів у реальному часі — усе це звіряється із сирими свічками та власним живим трек-рекордом трейдера. Виконання — ні.
|
||||
|
||||
Кожен ордер проходить через ліміти, зашиті в код поза досяжністю моделі:
|
||||
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| Ліміти позицій | Максимум одночасних позицій, номінал обмежено часткою від капіталу, одна позиція на символ |
|
||||
| Обмеження плеча | Жорсткі стелі застосовуються під час розрахунку розміру ордера, незалежно від запиту моделі |
|
||||
| Захист на боці біржі | Стоп-лос і тейк-профіт розміщуються на біржі одразу після кожного входу |
|
||||
| Автозакриття за просадкою | Прибуткові позиції, що віддають надто багато від свого піку, закриваються |
|
||||
| Обмеження частоти угод | Мінімальний час утримання, кулдауни на повторний вхід за символом, ліміти входів на цикл і на годину |
|
||||
| Безпечний режим | Повторювані збої моделі блокують нові входи, доки модель не відновиться |
|
||||
| Передстартова перевірка | Доступ до моделі, кошти в гаманці, стратегія та баланси бірж перевіряються, перш ніж трейдер зможе стартувати |
|
||||
|
||||
Кожне рішення зберігається разом із повним ходом міркувань моделі. Жодної позиції без документального сліду.
|
||||
|
||||
<br/>
|
||||
|
||||
## Термінал
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Autopilot** | Покроковий запуск: поповнення, підключення, депозит, старт — із серверною передстартовою перевіркою на кожному кроці |
|
||||
| **Strategy Studio** | Пресети стилів, набори монет, індикатори, плече, впевненість входу, власні промпти |
|
||||
| **Змагання** | Публічна таблиця лідерів за реалізованою дохідністю; кожен запис прив'язаний до своєї моделі |
|
||||
| **Дашборд** | Живі позиції, ордери, статистика та обґрунтування кожного рішення |
|
||||
|
||||
<details>
|
||||
<summary>Скріншоти</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Огляд | Графік ринку |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| Статистика торгівлі | Історія позицій |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| Редактор стратегій | Налаштування індикаторів |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
| Змагання | Конфігурація |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## Моделі
|
||||
|
||||
Вісім провайдерів із вашими власними ключами — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — включно з власними ендпоінтами та назвами моделей.
|
||||
|
||||
Або взагалі без ключів: [Claw402](https://claw402.ai) тарифікує використання моделей за кожен виклик у USDC через протокол x402. Гаманець у мережі Base замінює всі API-ключі.
|
||||
|
||||
| Провайдер | Доступ |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [AI-моделі з оплатою за використання та офіційною знижкою](https://claw402.ai) |
|
||||
|
||||
## Ринки
|
||||
|
||||
Криптовалютні безстрокові контракти на всіх дев'яти біржах. На Hyperliquid той самий рантайм також торгує токенізованими акціями США, сировинними товарами, індексами, валютами та pre-IPO перпетуалами — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — поряд із криптою.
|
||||
|
||||
<br/>
|
||||
|
||||
## Архітектура
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
<br/>
|
||||
|
||||
## Встановлення
|
||||
|
||||
### Linux / macOS
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (Хмара)
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### З вихідного коду
|
||||
**Windows** — встановіть [Docker Desktop](https://www.docker.com/products/docker-desktop/), потім:
|
||||
|
||||
```bash
|
||||
# Вимоги: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # бекенд
|
||||
cd web && npm install && npm run dev # фронтенд (новий термінал)
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
**З вихідного коду** — Go 1.21+, Node.js 18+:
|
||||
|
||||
## Посилання
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
**Оновлення** — запустіть інсталяційний скрипт ще раз; він оновить усе на місці.
|
||||
|
||||
<details>
|
||||
<summary>Розгортання на сервері</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**HTTPS через Cloudflare**
|
||||
|
||||
1. Додайте домен у [Cloudflare](https://dash.cloudflare.com) (безкоштовний план)
|
||||
2. A-запис → IP сервера, проксійований
|
||||
3. SSL/TLS → Flexible
|
||||
4. `TRANSPORT_ENCRYPTION=true` у `.env`
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## Документація
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| Сайт | [nofxai.com](https://nofxai.com) |
|
||||
| Панель | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API Документація | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [Початок роботи](../../getting-started/README.md) | Посібники з розгортання та біржових API |
|
||||
| [Архітектура](../../architecture/README.md) | Дизайн системи та індекс модулів |
|
||||
| [Модуль стратегій](../../architecture/STRATEGY_MODULE.md) | Вибір монет, AI-промпти, виконання |
|
||||
| [FAQ](../../guides/faq.en.md) | Поширені запитання |
|
||||
| [Усунення несправностей](../../guides/TROUBLESHOOTING.md) | Діагностика типових проблем |
|
||||
|
||||
> **Попередження**: AI автоторгівля несе значні ризики. Рекомендується лише для навчання/досліджень або тестування малих сум.
|
||||
## Спільнота
|
||||
|
||||
---
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [Живий дашборд](https://vergex.trade/explore)
|
||||
|
||||
## Участь у розробці
|
||||
|
||||
Код, документація, переклади та звіти про помилки — усе це вітається; див. [Посібник для контриб'юторів](../../../CONTRIBUTING.md), [Кодекс поведінки](../../../CODE_OF_CONDUCT.md) і [Політику безпеки](../../../SECURITY.md).
|
||||
|
||||
NOFX відстежує значущі внески й має намір винагороджувати контриб'юторів у міру зростання екосистеми. Пріоритетні issues мають більшу вагу.
|
||||
|
||||
| Внесок | Вага |
|
||||
| :---------------- | :----: |
|
||||
| PR до закріплених issues | ★★★★★★ |
|
||||
| Код (змерджені PR) | ★★★★★ |
|
||||
| Виправлення багів | ★★★★ |
|
||||
| Ідеї функцій | ★★★ |
|
||||
| Звіти про баги | ★★ |
|
||||
| Документація | ★★ |
|
||||
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## Спонсори
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
[Стати спонсором](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
<br/>
|
||||
|
||||
Якщо NOFX вам корисний, зірка допоможе іншим трейдерам його знайти.
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## Ліцензія
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>Автоматизована торгівля пов'язана зі значним ризиком. Стратегії на основі AI експериментальні й можуть втрачати гроші. Обирайте розмір позицій розважливо, розумійте кожен майданчик і ніколи не торгуйте коштами, втрату яких не можете собі дозволити. Повний [дисклеймер](../../../DISCLAIMER.md).</sub>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<h1 align="center">NOFX</h1>
|
||||
<p align="center"><strong>Được hậu thuẫn bởi <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Trợ lý giao dịch AI cá nhân của bạn.</strong><br/>
|
||||
<strong>Mọi thị trường. Mọi mô hình. Thanh toán USDC, không cần API key.</strong>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,158 +21,262 @@
|
||||
<a href="README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
NOFX là trợ lý giao dịch AI **tự chủ** mã nguồn mở. Không giống các công cụ AI truyền thống yêu cầu bạn cấu hình mô hình thủ công, quản lý API key và kết nối nguồn dữ liệu — AI của NOFX **tự nhận diện thị trường, tự chọn mô hình và tự lấy dữ liệu**. Không cần con người can thiệp. Bạn chỉ cần đặt chiến lược, AI xử lý mọi thứ còn lại.
|
||||
NOFX là một terminal giao dịch mã nguồn mở, nơi chiến lược chính là một mô hình ngôn ngữ. Mỗi trader chạy một vòng lặp liên tục — đọc cấu trúc thị trường, ra quyết định, thực thi, ghi lại lập luận — trong khi một runtime viết bằng Go ép mọi lệnh vào các giới hạn rủi ro cứng mà mô hình không thể vượt qua.
|
||||
|
||||
**Hoàn toàn tự chủ**: AI tự quyết định sử dụng mô hình nào, lấy dữ liệu thị trường gì, khi nào giao dịch. Không cần cấu hình mô hình thủ công. Không cần quản lý API key của nhiều dịch vụ. Chỉ cần nạp ví USDC và chạy.
|
||||
|
||||
Điểm khác biệt: **tích hợp thanh toán vi mô [x402](https://x402.org)**. Không cần API key. Nạp ví USDC và thanh toán theo yêu cầu. Ví chính là danh tính của bạn.
|
||||
Trader được kết hợp tự do: bất kỳ mô hình nào, bất kỳ sàn nào trong chín sàn, bất kỳ chiến lược nào. Chạy nhiều trader song song và so sánh chúng trên bảng xếp hạng công khai theo lợi nhuận đã thực hiện. Mọi thứ chạy trên máy của chính bạn; thông tin xác thực sàn được mã hóa khi lưu trữ và không bao giờ rời khỏi máy.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Mở **http://127.0.0.1:3000**. Xong.
|
||||
Terminal mở tại `http://127.0.0.1:3000`.
|
||||
|
||||
---
|
||||
**Lần chạy đầu tiên**
|
||||
|
||||
## Demo nhanh
|
||||
1. Đăng ký — tài khoản đầu tiên trở thành chủ sở hữu của instance.
|
||||
2. Làm theo quy trình khởi chạy có hướng dẫn: nạp **$1+ USDC** (mạng Base) vào ví phí AI được tạo sẵn cho bạn, sau đó kết nối Hyperliquid và nạp **$12+ USDC** để giao dịch.
|
||||
3. Khởi động **Autopilot**. AI quét thị trường vài phút một lần và tự mình giao dịch; mọi quyết định hiện lên dashboard ngay khi diễn ra. Dừng lại bất cứ lúc nào chỉ với một cú nhấp.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="Video demo nhanh của NOFX" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
Nhấp vào ảnh bìa để xem video demo.
|
||||
</p>
|
||||
## Đăng ký sàn giao dịch
|
||||
|
||||
---
|
||||
NOFX miễn phí và mã nguồn mở. Mở tài khoản qua các liên kết đối tác bên dưới sẽ được giảm phí giao dịch và góp phần tài trợ cho việc phát triển lâu dài.
|
||||
|
||||
## x402 hoạt động như thế nào
|
||||
|
||||
Quy trình truyền thống: đăng ký tài khoản → mua credits → lấy API key → quản lý quota → xoay key.
|
||||
|
||||
Quy trình x402:
|
||||
|
||||
```
|
||||
Yêu cầu → 402 (đây là giá) → ví ký USDC → thử lại → xong
|
||||
```
|
||||
|
||||
Không tài khoản. Không API key. Không trả trước. Một ví, tất cả mô hình.
|
||||
|
||||
### Nhà cung cấp x402 tích hợp
|
||||
|
||||
| Nhà cung cấp | Chain | Mô hình |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ mô hình |
|
||||
|
||||
---
|
||||
|
||||
## Tính năng
|
||||
|
||||
| Tính năng | Mô tả |
|
||||
|:--------|:------------|
|
||||
| **Đa AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — chuyển đổi bất cứ lúc nào |
|
||||
| **Đa Sàn** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Strategy Studio** | Trình xây dựng trực quan — nguồn coin, chỉ báo, kiểm soát rủi ro |
|
||||
| **AI Competition** | AI cạnh tranh thời gian thực, bảng xếp hạng hiệu suất |
|
||||
| **Telegram Agent** | Chat với trợ lý giao dịch — streaming, gọi công cụ, bộ nhớ |
|
||||
| **Dashboard** | Vị thế trực tiếp, P/L, nhật ký quyết định AI với Chain of Thought |
|
||||
|
||||
### Thị trường
|
||||
|
||||
Crypto · Cổ phiếu Mỹ · Forex · Kim loại
|
||||
|
||||
### Sàn giao dịch (CEX)
|
||||
|
||||
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| Sàn giao dịch | Trạng thái | Đăng ký kèm ưu đãi phí |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Đăng ký](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Đăng ký](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Đăng ký](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Đăng ký](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Đăng ký](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Đăng ký](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Đăng ký](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Sàn giao dịch (Perp-DEX)
|
||||
|
||||
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Đăng ký](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Đăng ký](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Đăng ký](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Đăng ký](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### Mô hình AI (Chế độ API Key)
|
||||
<br/>
|
||||
|
||||
| Mô hình AI | Trạng thái | Lấy API Key |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Lấy API Key](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Lấy API Key](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Lấy API Key](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Lấy API Key](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Lấy API Key](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Lấy API Key](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Lấy API Key](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Lấy API Key](https://platform.minimaxi.com) |
|
||||
## Demo
|
||||
|
||||
### Mô hình AI (Chế độ x402 — Không cần API Key)
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
15+ mô hình qua [Claw402](https://claw402.ai) — chỉ cần ví USDC
|
||||
<br/>
|
||||
|
||||
---
|
||||
## Mô hình đề xuất. Runtime định đoạt.
|
||||
|
||||
Các quyết định đến từ một mô hình ngôn ngữ đọc bộ dữ liệu [Claw402.ai](https://claw402.ai) · Vergex: bảng tín hiệu trực tiếp xếp hạng mọi thị trường theo thiên hướng và độ mạnh tín hiệu, tín hiệu chuyên sâu Signal Lab cho từng mã, bản đồ nhiệt chi phí vốn và thanh lý cho thấy "nhiên liệu" và "bức tường" của đám đông nằm ở đâu, cùng dòng tiền ròng theo thời gian thực — tất cả được đối chiếu với nến thô và thành tích thực chiến của chính trader. Việc thực thi thì không.
|
||||
|
||||
Mọi lệnh đều phải đi qua các giới hạn được thực thi bằng code, nằm ngoài tầm với của mô hình:
|
||||
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| Giới hạn vị thế | Số vị thế đồng thời tối đa, giá trị danh nghĩa bị chặn theo tỷ lệ vốn, mỗi symbol chỉ một vị thế |
|
||||
| Kẹp đòn bẩy | Trần cứng áp dụng ngay khi tính khối lượng lệnh, bất kể mô hình yêu cầu gì |
|
||||
| Bảo vệ phía sàn | Stop-loss và take-profit được đặt trên sàn ngay sau mỗi lần vào lệnh |
|
||||
| Tự đóng khi rút vốn từ đỉnh | Vị thế đang lãi nhưng để mất quá nhiều so với đỉnh sẽ bị đóng |
|
||||
| Điều tiết giao dịch | Thời gian giữ tối thiểu, thời gian chờ vào lại theo từng symbol, giới hạn số lần vào lệnh theo chu kỳ và theo giờ |
|
||||
| Chế độ an toàn | Mô hình lỗi liên tiếp sẽ bị chặn vào lệnh mới cho đến khi mô hình hồi phục |
|
||||
| Kiểm tra tiền khởi chạy | Quyền truy cập mô hình, tiền trong ví, chiến lược và số dư sàn được xác minh trước khi trader được phép khởi động |
|
||||
|
||||
Mỗi quyết định được lưu kèm toàn bộ lập luận của mô hình. Không có vị thế nào thiếu hồ sơ lưu vết.
|
||||
|
||||
<br/>
|
||||
|
||||
## Terminal
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Autopilot** | Khởi chạy có hướng dẫn: nạp phí, kết nối, nạp tiền, khởi động — với kiểm tra preflight phía máy chủ xuyên suốt |
|
||||
| **Strategy Studio** | Preset phong cách, tập coin, chỉ báo, đòn bẩy, độ tin cậy vào lệnh, prompt tùy chỉnh |
|
||||
| **Cạnh tranh** | Bảng xếp hạng công khai theo lợi nhuận đã thực hiện, mỗi mục được ghi nhận cho mô hình tạo ra nó |
|
||||
| **Dashboard** | Vị thế trực tiếp, lệnh, thống kê và lập luận đằng sau mỗi quyết định |
|
||||
|
||||
<details>
|
||||
<summary>Ảnh chụp màn hình</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Tổng quan | Biểu đồ thị trường |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| Thống kê giao dịch | Lịch sử vị thế |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| Trình soạn chiến lược | Cấu hình chỉ báo |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
| Cạnh tranh | Cấu hình |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## Mô hình
|
||||
|
||||
Tám nhà cung cấp với key của riêng bạn — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — bao gồm cả endpoint và tên mô hình tùy chỉnh.
|
||||
|
||||
Hoặc không cần key nào cả: [Claw402](https://claw402.ai) tính phí sử dụng mô hình theo từng lần gọi bằng USDC qua giao thức x402. Một chiếc ví trên Base thay thế mọi API key.
|
||||
|
||||
| Nhà cung cấp | Truy cập |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [Mô hình AI trả theo mức dùng với ưu đãi chính thức](https://claw402.ai) |
|
||||
|
||||
## Thị trường
|
||||
|
||||
Hợp đồng vĩnh cửu crypto trên cả chín sàn. Trên Hyperliquid, cùng một runtime còn giao dịch cổ phiếu Mỹ token hóa, hàng hóa, chỉ số, ngoại hối và hợp đồng vĩnh cửu pre-IPO — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — song song với crypto.
|
||||
|
||||
<br/>
|
||||
|
||||
## Kiến trúc
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
<br/>
|
||||
|
||||
## Cài đặt
|
||||
|
||||
### Linux / macOS
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (Cloud)
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Từ mã nguồn
|
||||
**Windows** — cài [Docker Desktop](https://www.docker.com/products/docker-desktop/), sau đó:
|
||||
|
||||
```bash
|
||||
# Yêu cầu: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend (terminal mới)
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
**Từ mã nguồn** — Go 1.21+, Node.js 18+:
|
||||
|
||||
## Liên kết
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
**Cập nhật** — chạy lại script cài đặt; nó tự nâng cấp tại chỗ.
|
||||
|
||||
<details>
|
||||
<summary>Triển khai lên máy chủ</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**HTTPS qua Cloudflare**
|
||||
|
||||
1. Thêm domain vào [Cloudflare](https://dash.cloudflare.com) (gói miễn phí)
|
||||
2. Bản ghi A → IP máy chủ, bật proxy
|
||||
3. SSL/TLS → Flexible
|
||||
4. `TRANSPORT_ENCRYPTION=true` trong `.env`
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## Tài liệu
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| Website | [nofxai.com](https://nofxai.com) |
|
||||
| Dashboard | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API Docs | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [Bắt đầu](../../getting-started/README.md) | Hướng dẫn triển khai và API sàn |
|
||||
| [Kiến trúc](../../architecture/README.md) | Thiết kế hệ thống và chỉ mục module |
|
||||
| [Module chiến lược](../../architecture/STRATEGY_MODULE.md) | Chọn coin, prompt AI, thực thi |
|
||||
| [FAQ](../../guides/faq.en.md) | Câu hỏi thường gặp |
|
||||
| [Khắc phục sự cố](../../guides/TROUBLESHOOTING.md) | Chẩn đoán các sự cố thường gặp |
|
||||
|
||||
> **Cảnh báo rủi ro**: Giao dịch tự động AI có rủi ro đáng kể. Chỉ nên sử dụng cho mục đích học tập/nghiên cứu hoặc số tiền nhỏ.
|
||||
## Cộng đồng
|
||||
|
||||
---
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [Dashboard trực tiếp](https://vergex.trade/explore)
|
||||
|
||||
## License
|
||||
## Đóng góp
|
||||
|
||||
Code, tài liệu, bản dịch và báo cáo lỗi đều được hoan nghênh — xem [Hướng dẫn đóng góp](../../../CONTRIBUTING.md), [Quy tắc ứng xử](../../../CODE_OF_CONDUCT.md) và [Chính sách bảo mật](../../../SECURITY.md).
|
||||
|
||||
NOFX theo dõi các đóng góp có ý nghĩa và dự định thưởng cho người đóng góp khi hệ sinh thái phát triển. Issue ưu tiên có trọng số cao hơn.
|
||||
|
||||
| Đóng góp | Trọng số |
|
||||
| :---------------- | :----: |
|
||||
| PR cho issue được ghim | ★★★★★★ |
|
||||
| Code (PR đã merge) | ★★★★★ |
|
||||
| Sửa lỗi | ★★★★ |
|
||||
| Ý tưởng tính năng | ★★★ |
|
||||
| Báo cáo lỗi | ★★ |
|
||||
| Tài liệu | ★★ |
|
||||
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## Nhà tài trợ
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
[Trở thành nhà tài trợ](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
<br/>
|
||||
|
||||
Nếu NOFX hữu ích với bạn, một ngôi sao sẽ giúp các trader khác tìm thấy nó.
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## Giấy phép
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>Giao dịch tự động tiềm ẩn rủi ro đáng kể. Các chiến lược do AI điều khiển mang tính thử nghiệm và có thể thua lỗ. Hãy đặt kích thước vị thế hợp lý, hiểu rõ từng sàn giao dịch, và đừng bao giờ giao dịch bằng số tiền mà bạn không thể để mất. Xem đầy đủ [tuyên bố miễn trừ trách nhiệm](../../../DISCLAIMER.md).</sub>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<h1 align="center">NOFX</h1>
|
||||
<p align="center"><strong>由 <a href="https://vergex.trade">vergex.trade</a> 支持</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<strong>你的个人 AI 交易助手。</strong><br/>
|
||||
<strong>任何市场。任何模型。用 USDC 付费,无需 API Key。</strong>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,224 +21,262 @@
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
> **语言声明:** 本中文版本文档仅为方便海外华人社区阅读而提供,不代表本软件面向中国大陆、香港、澳门或台湾地区用户开放。如您位于上述地区,请勿使用本软件。
|
||||
<br/>
|
||||
|
||||
---
|
||||
NOFX 是一个开源交易终端,策略本身就是一个语言模型。每个交易员运行一个持续循环——读取市场结构、做出决策、执行、记录推理过程——同时由 Go 运行时把每一笔订单钳制在模型无法越过的硬性风控限制之内。
|
||||
|
||||
NOFX 是一个开源的**自主式** AI 交易助手。与需要手动配置模型、管理 API Key、接入数据源的传统 AI 工具不同 —— NOFX 的 AI **自主感知市场、自选模型、自动获取数据**。零人工干预。你只需设定策略,AI 负责一切。
|
||||
|
||||
**完全自主**:AI 自行决定使用哪个模型、获取什么市场数据、何时交易。无需手动配置模型,无需管理各种服务的 API Key。只需充值 USDC 钱包,一键启动。
|
||||
|
||||
核心差异:**内置 [x402](https://x402.org) 微支付协议**。无需 API Key,充值 USDC 钱包即可按需付费。钱包就是你的身份。
|
||||
交易员可以自由组合:任意模型、九家交易所任选、任意策略。多个交易员并行运行,在公开排行榜上按已实现收益一较高下。一切都在你自己的机器上运行;交易所凭证加密存储,绝不外传。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
打开 **http://127.0.0.1:3000**,完成。
|
||||
终端会在 `http://127.0.0.1:3000` 打开。
|
||||
|
||||
---
|
||||
**首次运行**
|
||||
|
||||
## 快速演示
|
||||
1. 注册——第一个账户即成为该实例的所有者。
|
||||
2. 按引导式启动流程操作:向系统为你创建的 AI 费用钱包存入 **$1+ USDC**(Base 网络),然后连接 Hyperliquid 并存入 **$12+ USDC** 作为交易资金。
|
||||
3. 启动 **Autopilot**。AI 每隔几分钟扫描一次市场并自主交易;每一个决策都会实时出现在仪表板上。随时可以一键停止。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX 快速演示视频" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
点击封面图即可观看 Demo 视频。
|
||||
</p>
|
||||
## 注册交易所
|
||||
|
||||
---
|
||||
NOFX 免费且开源。通过下方合作伙伴链接开户可享受更低的交易手续费,同时也为项目的持续开发提供支持。
|
||||
|
||||
## x402 如何工作
|
||||
|
||||
传统流程:注册账号 → 购买额度 → 获取 API Key → 管理配额 → 轮换密钥。
|
||||
|
||||
x402 流程:
|
||||
|
||||
```
|
||||
请求 → 402(返回价格)→ 钱包签名 USDC → 重试 → 完成
|
||||
```
|
||||
|
||||
无需注册。无需 API Key。无需预付费。一个钱包,所有模型。
|
||||
|
||||
### 内置 x402 提供商
|
||||
|
||||
| 提供商 | 链 | 模型 |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4、Claude Opus、DeepSeek、Qwen、Grok、Gemini、Kimi — 15+ 模型 |
|
||||
|
||||
---
|
||||
|
||||
## 功能概览
|
||||
|
||||
| 功能 | 描述 |
|
||||
|:--------|:------------|
|
||||
| **多 AI** | DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi、MiniMax — 随时切换 |
|
||||
| **多交易所** | Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster、Lighter |
|
||||
| **策略工作室** | 可视化构建器 — 币种来源、指标、风控 |
|
||||
| **AI 竞赛** | AI 实时竞争,排行榜排名 |
|
||||
| **Telegram Agent** | 与交易助手对话 — 流式输出、工具调用、记忆 |
|
||||
| **回测实验室** | 历史模拟,权益曲线和性能指标 |
|
||||
| **仪表板** | 实时持仓、盈亏、AI 决策日志与思维链 |
|
||||
|
||||
### 市场
|
||||
|
||||
加密货币 · 美股 · 外汇 · 贵金属
|
||||
|
||||
### 交易所 (CEX)
|
||||
|
||||
| 交易所 | 状态 | 注册 (手续费折扣) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| 交易所 | 状态 | 享手续费折扣注册 |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [注册](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [注册](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [注册](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [注册](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [注册](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [注册](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### 交易所 (Perp-DEX)
|
||||
|
||||
| 交易所 | 状态 | 注册 (手续费折扣) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [注册](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [注册](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [注册](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [注册](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI 模型 (API Key 模式)
|
||||
<br/>
|
||||
|
||||
| AI 模型 | 状态 | 获取 API Key |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [获取 API Key](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **通义千问** | ✅ | [获取 API Key](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [获取 API Key](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [获取 API Key](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [获取 API Key](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [获取 API Key](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [获取 API Key](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [获取 API Key](https://platform.minimaxi.com) |
|
||||
## 演示
|
||||
|
||||
### AI 模型 (x402 模式 — 无需 API Key)
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
15+ 模型通过 [Claw402](https://claw402.ai) 接入 — 只需一个 USDC 钱包
|
||||
<br/>
|
||||
|
||||
---
|
||||
## 模型提议,运行时裁决
|
||||
|
||||
决策来自一个读取 [Claw402.ai](https://claw402.ai) · Vergex 数据栈的语言模型:实时信号看板为每个市场标注方向偏置与信号强度排名,Signal Lab 提供逐标的深度信号,成本与清算热力图揭示市场的"燃料"与"墙"在哪里,再叠加实时资金净流——并与原始 K 线和交易员自身的实盘战绩交叉验证。但执行不由它说了算。
|
||||
|
||||
每一笔订单都要经过代码层面强制执行的限制,模型无从干预:
|
||||
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| 持仓限制 | 最大并发持仓数、名义价值按权益比例封顶、每个币种仅允许一个持仓 |
|
||||
| 杠杆钳制 | 在订单定量时施加硬上限,与模型请求的杠杆无关 |
|
||||
| 交易所端保护 | 每次开仓后立即在交易所挂出止损和止盈单 |
|
||||
| 回撤自动平仓 | 盈利持仓从峰值回吐过多时会被自动平掉 |
|
||||
| 交易节流 | 最短持仓时间、单币种再入场冷却、单周期与单小时开仓次数限制 |
|
||||
| 安全模式 | 模型连续失败时阻止新开仓,直至模型恢复正常 |
|
||||
| 启动预检 | 交易员启动前须通过模型访问、钱包资金、策略和交易所余额的校验 |
|
||||
|
||||
每个决策都连同模型的完整推理一起存档。没有任何持仓是无据可查的。
|
||||
|
||||
<br/>
|
||||
|
||||
## 终端
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Autopilot** | 引导式启动:注资、连接、入金、启动——全程由服务端预检保驾护航 |
|
||||
| **Strategy Studio** | 风格预设、币种池、技术指标、杠杆、开仓置信度、自定义提示词 |
|
||||
| **竞赛** | 按已实现收益排名的公开排行榜,每个条目都标注所用模型 |
|
||||
| **仪表板** | 实时持仓、订单、统计数据,以及每个决策背后的推理 |
|
||||
|
||||
<details>
|
||||
<summary>截图</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| 概览 | 行情图表 |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| 交易统计 | 持仓历史 |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| 策略编辑器 | 指标配置 |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
| 竞赛 | 配置 |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## 模型
|
||||
|
||||
八家提供商,使用你自己的密钥——DeepSeek、OpenAI、Claude、Qwen、Gemini、Grok、Kimi、MiniMax——并支持自定义端点和模型名称。
|
||||
|
||||
或者完全不需要密钥:[Claw402](https://claw402.ai) 通过 x402 协议以 USDC 按次计量模型用量。一个 Base 链上的钱包即可替代所有 API 密钥。
|
||||
|
||||
| 提供商 | 接入方式 |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [按量付费的 AI 模型,享官方折扣](https://claw402.ai) |
|
||||
|
||||
## 市场
|
||||
|
||||
九家交易所全部支持加密货币永续合约。在 Hyperliquid 上,同一套运行时还可以交易代币化美股、大宗商品、指数、外汇和 pre-IPO 永续合约——TSLA、NVDA、GOLD、SPX、EUR、OPENAI——与加密资产并行。
|
||||
|
||||
<br/>
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
<br/>
|
||||
|
||||
## 安装
|
||||
|
||||
### Linux / macOS
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (云部署)
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
安装 [Docker Desktop](https://www.docker.com/products/docker-desktop/),然后:
|
||||
**Windows** —— 安装 [Docker Desktop](https://www.docker.com/products/docker-desktop/),然后:
|
||||
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 从源码构建
|
||||
**从源码构建** —— 需要 Go 1.21+、Node.js 18+:
|
||||
|
||||
```bash
|
||||
# 前置条件: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # 后端
|
||||
cd web && npm install && npm run dev # 前端 (新终端)
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
### 更新
|
||||
**更新** —— 重新运行安装脚本,即可原地升级。
|
||||
|
||||
<details>
|
||||
<summary>服务器部署</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
---
|
||||
**通过 Cloudflare 启用 HTTPS**
|
||||
|
||||
## 配置
|
||||
1. 将域名添加到 [Cloudflare](https://dash.cloudflare.com)(免费套餐即可)
|
||||
2. A 记录 → 服务器 IP,开启代理
|
||||
3. SSL/TLS → Flexible
|
||||
4. 在 `.env` 中设置 `TRANSPORT_ENCRYPTION=true`
|
||||
|
||||
**新手模式**:首次使用的用户可以在注册时选择新手模式,系统会引导你逐步完成 AI、交易所和策略的配置。
|
||||
</details>
|
||||
|
||||
**进阶模式**:
|
||||
|
||||
1. **AI** — 添加 API Key 或配置 x402 钱包
|
||||
2. **交易所** — 连接交易所 API 凭证
|
||||
3. **策略** — 在策略工作室构建
|
||||
4. **交易员** — 组合 AI + 交易所 + 策略
|
||||
5. **交易** — 从仪表板启动
|
||||
|
||||
所有操作通过 Web 界面完成:**http://127.0.0.1:3000**
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## 文档
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| [架构概览](../../architecture/README.md) | 系统设计和模块索引 |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [快速开始](../../getting-started/README.zh-CN.md) | 部署与交易所 API 指南 |
|
||||
| [架构](../../architecture/README.md) | 系统设计与模块索引 |
|
||||
| [策略模块](../../architecture/STRATEGY_MODULE.md) | 币种选择、AI 提示词、执行 |
|
||||
| [常见问题](../../faq/README.md) | FAQ |
|
||||
| [快速开始](../../getting-started/README.md) | 部署指南 |
|
||||
| [常见问题](../../guides/faq.zh-CN.md) | 常见疑问解答 |
|
||||
| [故障排查](../../guides/TROUBLESHOOTING.zh-CN.md) | 常见问题诊断 |
|
||||
|
||||
---
|
||||
## 社区
|
||||
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [实时仪表板](https://vergex.trade/explore)
|
||||
|
||||
## 贡献
|
||||
|
||||
查看 [贡献指南](../../../CONTRIBUTING.md) · [行为准则](../../../CODE_OF_CONDUCT.md) · [安全政策](../../../SECURITY.md)
|
||||
代码、文档、翻译和 Bug 报告都欢迎——参见[贡献指南](../../../CONTRIBUTING.md)、[行为准则](../../../CODE_OF_CONDUCT.md)和[安全政策](../../../SECURITY.md)。
|
||||
|
||||
### 贡献者空投计划
|
||||
|
||||
所有贡献在 GitHub 上追踪。当 NOFX 产生收入时,贡献者将获得空投。
|
||||
|
||||
**解决 [置顶 Issue](https://github.com/NoFxAiOS/nofx/issues) 的 PR 获得最高奖励!**
|
||||
NOFX 会记录有价值的贡献,并计划随着生态发展回馈贡献者。优先级 Issue 拥有更高权重。
|
||||
|
||||
| 贡献类型 | 权重 |
|
||||
|:-------------|:------:|
|
||||
| 置顶 Issue PR | ★★★★★★ |
|
||||
| 代码提交 (合并的 PR) | ★★★★★ |
|
||||
| :---------------- | :----: |
|
||||
| 置顶 Issue 的 PR | ★★★★★★ |
|
||||
| 代码(已合并 PR) | ★★★★★ |
|
||||
| Bug 修复 | ★★★★ |
|
||||
| 功能建议 | ★★★ |
|
||||
| Bug 报告 | ★★ |
|
||||
| 文档 | ★★ |
|
||||
|
||||
---
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## 链接
|
||||
## 赞助者
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| 官网 | [nofxai.com](https://nofxai.com) |
|
||||
| 数据面板 | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API 文档 | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
> **风险提示**: AI 自动交易存在重大风险。建议仅用于学习/研究或小额测试。
|
||||
[成为赞助者](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## License
|
||||
如果 NOFX 对你有帮助,点个 Star 能让更多交易者发现它。
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## 许可证
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>自动化交易存在重大风险。AI 驱动的策略尚处实验阶段,可能造成亏损。请合理控制仓位规模,充分了解每个交易场所,切勿投入无法承受损失的资金。完整[免责声明](../../../DISCLAIMER.md)。</sub>
|
||||
|
||||
91
go.mod
91
go.mod
@@ -1,129 +1,104 @@
|
||||
module nofx
|
||||
|
||||
go 1.25.3
|
||||
go 1.25.11
|
||||
|
||||
require (
|
||||
github.com/adshao/go-binance/v2 v2.8.9
|
||||
github.com/agiledragon/gomonkey/v2 v2.13.0
|
||||
github.com/ethereum/go-ethereum v1.16.7
|
||||
github.com/antihax/optional v1.0.0
|
||||
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6
|
||||
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48
|
||||
github.com/ethereum/go-ethereum v1.17.3
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/sonirico/go-hyperliquid v0.26.0
|
||||
github.com/sonirico/go-hyperliquid v0.36.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.42.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/text v0.37.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
modernc.org/sqlite v1.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.0.0-rc.1 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect
|
||||
github.com/antihax/optional v1.0.0 // indirect
|
||||
github.com/armon/go-radix v1.0.0 // indirect
|
||||
github.com/bitly/go-simplejson v0.5.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
||||
github.com/blendle/zapdriver v1.3.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/consensys/gnark-crypto v0.19.0 // indirect
|
||||
github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
|
||||
github.com/consensys/gnark-crypto v0.19.2 // indirect
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elastic/go-sysinfo v1.15.4 // indirect
|
||||
github.com/elastic/go-windows v1.0.2 // indirect
|
||||
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48 // indirect
|
||||
github.com/elliottech/poseidon_crypto v0.0.11 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
|
||||
github.com/ethereum/go-verkle v0.2.2 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gagliardetto/binary v0.8.0 // indirect
|
||||
github.com/gagliardetto/solana-go v1.14.0 // indirect
|
||||
github.com/gagliardetto/treeout v0.1.4 // indirect
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.4 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/holiman/uint256 v1.3.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/jpillora/backoff v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.16.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mailru/easyjson v0.9.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sonirico/vago v0.10.0 // indirect
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd // indirect
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect
|
||||
github.com/sonirico/vago v0.11.4 // indirect
|
||||
github.com/sonirico/vago/lol v0.1.0 // indirect
|
||||
github.com/supranational/blst v0.3.16 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.7 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 // indirect
|
||||
go.elastic.co/apm/v2 v2.7.1 // indirect
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.2 // indirect
|
||||
go.elastic.co/apm/v2 v2.7.2 // indirect
|
||||
go.elastic.co/fastjson v1.5.1 // indirect
|
||||
go.mongodb.org/mongo-driver v1.12.2 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
go.uber.org/ratelimit v0.2.0 // indirect
|
||||
go.uber.org/zap v1.21.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/term v0.35.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
howett.net/plist v1.0.1 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
247
go.sum
247
go.sum
@@ -1,34 +1,19 @@
|
||||
filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU=
|
||||
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
|
||||
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
|
||||
github.com/adshao/go-binance/v2 v2.8.7 h1:n7jkhwIHMdtd/9ZU2gTqFV15XVSbUCjyFlOUAtTd8uU=
|
||||
github.com/adshao/go-binance/v2 v2.8.7/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM=
|
||||
github.com/adshao/go-binance/v2 v2.8.9 h1:NX+4u/LgEmrjTS7OMWU+9ZgfHKFM61RPhnr9/SqWPhc=
|
||||
github.com/adshao/go-binance/v2 v2.8.9/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM=
|
||||
github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo=
|
||||
github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 h1:MzBOUgng9orim59UnfUTLRjMpd09C5uEVQ6RPGeCaVI=
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129/go.mod h1:rFgpPQZYZ8vdbc+48xibu8ALc3yeyd64IhHS+PU6Yyg=
|
||||
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
|
||||
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
|
||||
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
||||
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
|
||||
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
|
||||
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
|
||||
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE=
|
||||
github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 h1:41FLQtKmxWEdyjdgrAm9lZFdS0Ax2XsDxkd/fuztsyQ=
|
||||
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6/go.mod h1:P22TFRynmYRrquJCPalKxZgIIIc9+PkC4kQPeejitsI=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
@@ -37,15 +22,12 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/consensys/gnark-crypto v0.19.0 h1:zXCqeY2txSaMl6G5wFpZzMWJU9HPNh8qxPnYJ1BL9vA=
|
||||
github.com/consensys/gnark-crypto v0.19.0/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
|
||||
github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80=
|
||||
github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg=
|
||||
github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg=
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM=
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc=
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -65,26 +47,14 @@ github.com/elliottech/poseidon_crypto v0.0.11 h1:iX4rCg0m1XIX/7mhXVUEYUJIdQD57zN
|
||||
github.com/elliottech/poseidon_crypto v0.0.11/go.mod h1:NhWxSjPGr5JXRuB2Aepl/+ZrbmUG3hvku/GarB1JR8c=
|
||||
github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
|
||||
github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs=
|
||||
github.com/ethereum/go-ethereum v1.16.5 h1:GZI995PZkzP7ySCxEFaOPzS8+bd8NldE//1qvQDQpe0=
|
||||
github.com/ethereum/go-ethereum v1.16.5/go.mod h1:kId9vOtlYg3PZk9VwKbGlQmSACB5ESPTBGT+M9zjmok=
|
||||
github.com/ethereum/go-ethereum v1.16.7 h1:qeM4TvbrWK0UC0tgkZ7NiRsmBGwsjqc64BHo20U59UQ=
|
||||
github.com/ethereum/go-ethereum v1.16.7/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk=
|
||||
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
|
||||
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
|
||||
github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q=
|
||||
github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A=
|
||||
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
|
||||
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg=
|
||||
github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c=
|
||||
github.com/gagliardetto/solana-go v1.14.0 h1:3WfAi70jOOjAJ0deFMjdhFYlLXATF4tOQXsDNWJtOLw=
|
||||
github.com/gagliardetto/solana-go v1.14.0/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k=
|
||||
github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw=
|
||||
github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok=
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3 h1:JQ2+s1pG4bL+JeLQyGy9c7YLr7hxRI8g7vkAuQYl75k=
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3/go.mod h1:racCcjrdyOUbRDO5eCUGUiyDPrF/ZmwBj/bupPZTVLY=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -110,14 +80,12 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -136,8 +104,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
@@ -154,18 +122,10 @@ github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
|
||||
github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
@@ -176,10 +136,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
@@ -187,50 +145,34 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
|
||||
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
|
||||
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
|
||||
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk=
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
@@ -238,32 +180,24 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/sonirico/go-hyperliquid v0.17.0 h1:eXYACWupwu41O1VtKw17dqe9oOLQ1A2nRElGhg5Ox+4=
|
||||
github.com/sonirico/go-hyperliquid v0.17.0/go.mod h1:sH51Vsu+tPUwc95TL2MoQ8YXSewLWBEJirgzo7sZx6w=
|
||||
github.com/sonirico/go-hyperliquid v0.26.0 h1:C2KjaD2R/AxH1FOPl6W1LyvAx/XUHdTQYgjb4PUcPN0=
|
||||
github.com/sonirico/go-hyperliquid v0.26.0/go.mod h1:SYzazq5hqC8lI1+MgSO0aJVrf0TAfyibp5NjUqnwv2I=
|
||||
github.com/sonirico/vago v0.9.0 h1:DF2OWW2Aaf1xPZmnFv79kBrHmjKX3mVvMbP08vERlKo=
|
||||
github.com/sonirico/vago v0.9.0/go.mod h1:fZxV1RzMe2eaZokbbDvuyoOzG3YapzqRQoOiD9VyJH0=
|
||||
github.com/sonirico/vago v0.10.0 h1:y+4Wo56tK+88a5lUwVrZUO2RRLaPcBgjI5cupKpT1Oc=
|
||||
github.com/sonirico/vago v0.10.0/go.mod h1:HCfnyPHId7V+zBZ5BLfIsdHIO+ewo6+uhF1N0hxlldc=
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd h1:rbvNORW8/0AtH/8W/SUwUykbuh2SeQBrNgFLqYpGTWY=
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd/go.mod h1:pteYccB32seEf19i0TPk7DKdEZdWJ/n9K9DF8AFeXGU=
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 h1:RN5mrigyirb8anBEtdjtHFIufXdacyTi6i4KBfeNXeo=
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU=
|
||||
github.com/sonirico/go-hyperliquid v0.36.0 h1:97aNCFf7PbvXtpCh+kdpqlAmLCyu4vB/USaKL73mSB8=
|
||||
github.com/sonirico/go-hyperliquid v0.36.0/go.mod h1:6UkIfvbqOPtCNcdC2TQ7vVPy5k8pqgoMucbGep4gCuY=
|
||||
github.com/sonirico/vago v0.11.4 h1:KlKh5iYYxKii1bhReKDIE10LPz/GPPqAcn4EvZl4t54=
|
||||
github.com/sonirico/vago v0.11.4/go.mod h1:HCfnyPHId7V+zBZ5BLfIsdHIO+ewo6+uhF1N0hxlldc=
|
||||
github.com/sonirico/vago/lol v0.1.0 h1:YjI+JAQ6enMYlpoM23w6J+1b11TJ8rqPpuD2NDHdFlA=
|
||||
github.com/sonirico/vago/lol v0.1.0/go.mod h1:k8CVrcWhKbPSX5821lt8L64z/DaST2TUaxiJOdPaSA0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@@ -272,7 +206,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
|
||||
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
|
||||
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
@@ -281,128 +214,62 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
|
||||
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 h1:C9+KrlqS8F4SZFu+ct0Jmv2YLmzDhWsI8htK6exd3vg=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1/go.mod h1:wXViB7paxMUrERgZrmUb+0FCqgb13Dull1JOOd8Hcj0=
|
||||
go.elastic.co/apm/v2 v2.7.1 h1:OFjARuESjBsxw7wHrEAnfSVNCHGBATXSI/kPvBARY/A=
|
||||
go.elastic.co/apm/v2 v2.7.1/go.mod h1:tQhBAjwh93b2leuAdzGwta/sP7Yc7QoKTSjeIHHDuog=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.2 h1:JPgmhFEUDfjvIrfZdWEgkwu5H2Nzhze6GFan+qoUQYo=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.2/go.mod h1:oQIxTgTMMef1FgFghymN+GCXpWhW6rpQRihV8Gjoi+w=
|
||||
go.elastic.co/apm/v2 v2.7.2 h1:0blxpxOMOcpBTz034RBqvEw806y0CDJwo/ut+2wZsHA=
|
||||
go.elastic.co/apm/v2 v2.7.2/go.mod h1:KJcwwsaouDzcLd8EviAO+y8yrfZzD6PhUCEg82bvLV4=
|
||||
go.elastic.co/fastjson v1.5.1 h1:zeh1xHrFH79aQ6Xsw7YxixvnOdAl3OSv0xch/jRDzko=
|
||||
go.elastic.co/fastjson v1.5.1/go.mod h1:WtvH5wz8z9pDOPqNYSYKoLLv/9zCWZLeejHWuvdL/EM=
|
||||
go.mongodb.org/mongo-driver v1.12.2 h1:gbWY1bJkkmUB9jjZzcdhOL8O85N9H+Vvsf2yFN0RDws=
|
||||
go.mongodb.org/mongo-driver v1.12.2/go.mod h1:/rGBTebI3XYboVmgz+Wv3Bcbl3aD0QF9zl6kDDw18rQ=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/ratelimit v0.2.0 h1:UQE2Bgi7p2B85uP5dC2bbRtig0C+OeNRnNEafLjsLPA=
|
||||
go.uber.org/ratelimit v0.2.0/go.mod h1:YYBV4e4naJvhpitQrWJu1vCpgB7CboMe0qhltKt6mUg=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
|
||||
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.5 h1:I0hpTIvD5rII+8LgYGrHMA2d4SQPoL6u7ZvJakWKsiA=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.5/go.mod h1:dRos81TkW9C1WJt6tTaE+uV2Lo8qJT3AG2b35+CB/nQ=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.6/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
|
||||
642
kernel/engine.go
642
kernel/engine.go
@@ -6,14 +6,17 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/provider/hyperliquid"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/provider/vergex"
|
||||
"nofx/security"
|
||||
"nofx/store"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -103,6 +106,7 @@ type Context struct {
|
||||
MultiTFMarket map[string]map[string]*market.Data `json:"-"`
|
||||
OITopDataMap map[string]*OITopData `json:"-"`
|
||||
QuantDataMap map[string]*QuantData `json:"-"`
|
||||
VergexDataMap map[string]*vergex.MarketAnalysis `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
|
||||
@@ -184,6 +188,8 @@ type OIDeltaData struct {
|
||||
type StrategyEngine struct {
|
||||
config *store.StrategyConfig
|
||||
nofxosClient *nofxos.Client
|
||||
vergexClient *vergex.Client
|
||||
vergexRankingCache map[string]*vergex.SignalRankItem
|
||||
}
|
||||
|
||||
// NewStrategyEngine creates strategy execution engine.
|
||||
@@ -216,14 +222,44 @@ func NewStrategyEngine(config *store.StrategyConfig, claw402WalletKey ...string)
|
||||
} else {
|
||||
logger.Warnf("⚠️ Failed to init claw402 data client: %v (using direct nofxos.ai)", err)
|
||||
}
|
||||
|
||||
vergexClient, err := vergex.NewClient(claw402URL, walletKey, &logger.MCPLogger{})
|
||||
if err == nil {
|
||||
logger.Infof("🔗 Vergex signals routed through claw402 (%s)", claw402URL)
|
||||
} else {
|
||||
logger.Warnf("⚠️ Failed to init Vergex claw402 client: %v", err)
|
||||
}
|
||||
return &StrategyEngine{
|
||||
config: config,
|
||||
nofxosClient: client,
|
||||
vergexClient: vergexClient,
|
||||
vergexRankingCache: make(map[string]*vergex.SignalRankItem),
|
||||
}
|
||||
}
|
||||
|
||||
return &StrategyEngine{
|
||||
config: config,
|
||||
nofxosClient: client,
|
||||
vergexRankingCache: make(map[string]*vergex.SignalRankItem),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) usesHyperliquidNativeUniverse() bool {
|
||||
if e == nil || e.config == nil {
|
||||
return false
|
||||
}
|
||||
source := e.config.CoinSource
|
||||
if source.SourceType == "hyper_all" || source.SourceType == "hyper_main" || source.SourceType == "hyper_rank" || source.SourceType == "vergex_signal" || source.UseHyperAll || source.UseHyperMain {
|
||||
return true
|
||||
}
|
||||
for _, symbol := range source.StaticCoins {
|
||||
if market.IsXyzDexAsset(symbol) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetRiskControlConfig gets risk control configuration
|
||||
func (e *StrategyEngine) GetRiskControlConfig() store.RiskControlConfig {
|
||||
return e.config.RiskControl
|
||||
@@ -368,6 +404,27 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
}
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "hyper_rank":
|
||||
coins, err := e.getHyperRankCoins(coinSource.HyperRankCategory, coinSource.HyperRankDirection, coinSource.HyperRankLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "vergex_signal":
|
||||
coins, err := e.getVergexSignalCoins(
|
||||
coinSource.VergexLimit,
|
||||
coinSource.VergexMarketType,
|
||||
coinSource.VergexChain,
|
||||
coinSource.VergexLiqBand,
|
||||
coinSource.HyperRankCategory,
|
||||
coinSource.StaticCoins,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "mixed":
|
||||
if coinSource.UseAI500 {
|
||||
poolCoins, err := e.getAI500Coins(coinSource.AI500Limit)
|
||||
@@ -586,6 +643,297 @@ func (e *StrategyEngine) getHyperMainCoins(limit int) ([]CandidateCoin, error) {
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func clampHyperRankLimit(limit int) int {
|
||||
if limit <= 0 {
|
||||
return 5
|
||||
}
|
||||
if limit > 10 {
|
||||
return 10
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) getHyperRankCoins(category, direction string, limit int) ([]CandidateCoin, error) {
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
if category == "" {
|
||||
category = "stock"
|
||||
}
|
||||
direction = strings.ToLower(strings.TrimSpace(direction))
|
||||
if direction == "" {
|
||||
direction = "gainers"
|
||||
}
|
||||
limit = clampHyperRankLimit(limit)
|
||||
|
||||
ctx := context.Background()
|
||||
var ranked []struct {
|
||||
symbol string
|
||||
info hyperliquid.CoinInfo
|
||||
cat string
|
||||
}
|
||||
|
||||
if category == "crypto" || category == "all" {
|
||||
coins, err := hyperliquid.GetPerpDexCoins(ctx, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Hyperliquid crypto ranking: %w", err)
|
||||
}
|
||||
for _, coin := range coins {
|
||||
ranked = append(ranked, struct {
|
||||
symbol string
|
||||
info hyperliquid.CoinInfo
|
||||
cat string
|
||||
}{symbol: market.Normalize(coin.Symbol + "USDT"), info: coin, cat: "crypto"})
|
||||
}
|
||||
}
|
||||
|
||||
if category != "crypto" {
|
||||
coins, err := hyperliquid.GetPerpDexCoins(ctx, "xyz")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Hyperliquid XYZ ranking: %w", err)
|
||||
}
|
||||
for _, coin := range coins {
|
||||
base := strings.TrimPrefix(coin.Symbol, "xyz:")
|
||||
cat := hyperliquid.XYZCategory(base)
|
||||
if category != "all" && cat != category {
|
||||
continue
|
||||
}
|
||||
ranked = append(ranked, struct {
|
||||
symbol string
|
||||
info hyperliquid.CoinInfo
|
||||
cat string
|
||||
}{symbol: hyperliquid.FormatCoinForAPI("xyz:" + base), info: coin, cat: cat})
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
switch direction {
|
||||
case "losers":
|
||||
return ranked[i].info.Change24hPct < ranked[j].info.Change24hPct
|
||||
case "volume":
|
||||
return ranked[i].info.Volume24h > ranked[j].info.Volume24h
|
||||
default:
|
||||
return ranked[i].info.Change24hPct > ranked[j].info.Change24hPct
|
||||
}
|
||||
})
|
||||
|
||||
if len(ranked) > limit {
|
||||
ranked = ranked[:limit]
|
||||
}
|
||||
candidates := make([]CandidateCoin, 0, len(ranked))
|
||||
source := fmt.Sprintf("hyper_rank_%s_%s", category, direction)
|
||||
for _, item := range ranked {
|
||||
candidates = append(candidates, CandidateCoin{Symbol: item.symbol, Sources: []string{source}})
|
||||
}
|
||||
logger.Infof("✅ Loaded %d Hyperliquid rank coins (%s/%s, capped at %d)", len(candidates), category, direction, limit)
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) getVergexSignalCoins(limit int, marketType, chain, liqBand, category string, selectedSymbols []string) ([]CandidateCoin, error) {
|
||||
if e.vergexClient == nil {
|
||||
return nil, fmt.Errorf("vergex signal source requires a configured claw402 wallet")
|
||||
}
|
||||
if marketType == "" {
|
||||
marketType = vergex.DefaultMarketType
|
||||
}
|
||||
chain = vergex.QueryChain(chain)
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
if limit > store.MaxCandidateCoins {
|
||||
limit = store.MaxCandidateCoins
|
||||
}
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
|
||||
ranking, err := e.vergexClient.GetSignalRanking(context.Background(), vergex.Query{
|
||||
Chain: chain,
|
||||
LiqBand: liqBand,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Vergex signal ranking: %w", err)
|
||||
}
|
||||
|
||||
rankedItems := vergex.FilterSignalRankingItems(ranking.Items, marketType, store.MaxCandidateCoins)
|
||||
if len(rankedItems) == 0 && strings.TrimSpace(chain) != "" {
|
||||
fallbackRanking, fallbackErr := e.vergexClient.GetSignalRanking(context.Background(), vergex.Query{
|
||||
LiqBand: liqBand,
|
||||
})
|
||||
if fallbackErr == nil {
|
||||
fallbackItems := vergex.FilterSignalRankingItems(fallbackRanking.Items, marketType, store.MaxCandidateCoins)
|
||||
if len(fallbackItems) > 0 {
|
||||
logger.Infof("✅ Vergex signal ranking returned TradeFi items after retrying without chain filter (chain=%s)", chain)
|
||||
ranking = fallbackRanking
|
||||
rankedItems = fallbackItems
|
||||
}
|
||||
} else {
|
||||
logger.Warnf("⚠️ Vergex signal ranking retry without chain failed: %v", fallbackErr)
|
||||
}
|
||||
}
|
||||
e.vergexRankingCache = make(map[string]*vergex.SignalRankItem, len(rankedItems))
|
||||
for _, item := range rankedItems {
|
||||
itemCopy := item
|
||||
if symbol := vergex.TradableSymbolForMarket(item.MarketType, item.Symbol); symbol != "" {
|
||||
e.vergexRankingCache[symbol] = &itemCopy
|
||||
}
|
||||
}
|
||||
|
||||
if len(selectedSymbols) > 0 {
|
||||
candidates := make([]CandidateCoin, 0, minInt(len(selectedSymbols), limit))
|
||||
seen := make(map[string]bool)
|
||||
for _, raw := range selectedSymbols {
|
||||
symbol := vergex.TradableSymbolForMarket(marketType, raw)
|
||||
if symbol == "" || seen[symbol] {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"vergex_signal"},
|
||||
})
|
||||
seen[symbol] = true
|
||||
if len(candidates) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, fmt.Errorf("selected Claw402 symbols are not tradable %s items", marketType)
|
||||
}
|
||||
logger.Infof("✅ Loaded %d selected Vergex candidates (%s)", len(candidates), marketType)
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// Direction-balanced selection: interleave the top bullish and top bearish
|
||||
// signals so the candidate universe carries BOTH long and short ideas every
|
||||
// cycle (instead of filling up with whichever bias ranks highest). This is
|
||||
// what lets the AI actually judge — and trade — both directions.
|
||||
var bullItems, bearItems, otherItems []vergex.SignalRankItem
|
||||
for _, item := range rankedItems {
|
||||
if category != "" && category != "all" && item.Category != category {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(item.Bias)) {
|
||||
case "bearish", "short", "sell":
|
||||
bearItems = append(bearItems, item)
|
||||
case "bullish", "long", "buy":
|
||||
bullItems = append(bullItems, item)
|
||||
default:
|
||||
otherItems = append(otherItems, item)
|
||||
}
|
||||
}
|
||||
items := make([]vergex.SignalRankItem, 0, limit)
|
||||
bi, ri, oi := 0, 0, 0
|
||||
for len(items) < limit {
|
||||
progressed := false
|
||||
if bi < len(bullItems) {
|
||||
items = append(items, bullItems[bi])
|
||||
bi++
|
||||
progressed = true
|
||||
if len(items) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if ri < len(bearItems) {
|
||||
items = append(items, bearItems[ri])
|
||||
ri++
|
||||
progressed = true
|
||||
if len(items) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !progressed {
|
||||
if oi < len(otherItems) {
|
||||
items = append(items, otherItems[oi])
|
||||
oi++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
if category != "" && category != "all" {
|
||||
return nil, fmt.Errorf("vergex signal ranking returned no tradable %s items in category %s", marketType, category)
|
||||
}
|
||||
return nil, fmt.Errorf("vergex signal ranking returned no tradable %s items", marketType)
|
||||
}
|
||||
|
||||
candidates := make([]CandidateCoin, 0, len(items))
|
||||
for _, item := range items {
|
||||
itemCopy := item
|
||||
symbol := vergex.TradableSymbolForMarket(item.MarketType, item.Symbol)
|
||||
if symbol == "" {
|
||||
continue
|
||||
}
|
||||
e.vergexRankingCache[symbol] = &itemCopy
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"vergex_signal"},
|
||||
})
|
||||
}
|
||||
logger.Infof("✅ Loaded %d Vergex signal candidates (%s/%s, capped at %d)", len(candidates), marketType, withDefaultText(category, "all"), limit)
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// DirectionalCandidate is a Vergex board candidate with its directional
|
||||
// signal strength (the board z-score; sign follows the bias direction).
|
||||
type DirectionalCandidate struct {
|
||||
Symbol string
|
||||
Score float64
|
||||
}
|
||||
|
||||
// DirectionalCandidates returns bullish (long) and bearish (short) candidates
|
||||
// from the most recent Vergex signal ranking, each ordered by upstream rank
|
||||
// (strongest first) and carrying the signal score so callers can require a
|
||||
// minimum strength. Only populated for vergex_signal coin sources, since that
|
||||
// is the only source carrying a per-symbol directional bias.
|
||||
func (e *StrategyEngine) DirectionalCandidates() (bullish []DirectionalCandidate, bearish []DirectionalCandidate) {
|
||||
if e == nil || len(e.vergexRankingCache) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
type ranked struct {
|
||||
cand DirectionalCandidate
|
||||
rank int
|
||||
}
|
||||
rankKey := func(r int) int {
|
||||
if r > 0 {
|
||||
return r
|
||||
}
|
||||
return 1 << 30
|
||||
}
|
||||
var bl, br []ranked
|
||||
for sym, item := range e.vergexRankingCache {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
entry := ranked{DirectionalCandidate{Symbol: sym, Score: item.Score}, item.Rank}
|
||||
switch strings.ToLower(strings.TrimSpace(item.Bias)) {
|
||||
case "bearish", "short", "sell":
|
||||
br = append(br, entry)
|
||||
case "bullish", "long", "buy":
|
||||
bl = append(bl, entry)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(bl, func(i, j int) bool { return rankKey(bl[i].rank) < rankKey(bl[j].rank) })
|
||||
sort.SliceStable(br, func(i, j int) bool { return rankKey(br[i].rank) < rankKey(br[j].rank) })
|
||||
for _, r := range bl {
|
||||
bullish = append(bullish, r.cand)
|
||||
}
|
||||
for _, r := range br {
|
||||
bearish = append(bearish, r.cand)
|
||||
}
|
||||
return bullish, bearish
|
||||
}
|
||||
|
||||
func withDefaultText(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// External & Quant Data
|
||||
// ============================================================================
|
||||
@@ -677,6 +1025,10 @@ func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) {
|
||||
if !e.config.Indicators.EnableQuantData {
|
||||
return nil, nil
|
||||
}
|
||||
if e.usesHyperliquidNativeUniverse() || market.IsXyzDexAsset(symbol) {
|
||||
logger.Infof("⏭️ Skipping NofxOS quant data for Hyperliquid symbol %s; using native Hyperliquid klines/mark data only", symbol)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Use nofxos client with unified API key
|
||||
include := "oi,price"
|
||||
@@ -767,12 +1119,292 @@ func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*Quant
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) FetchVergexDataBatch(ctx context.Context, symbols []string) map[string]*vergex.MarketAnalysis {
|
||||
result := make(map[string]*vergex.MarketAnalysis)
|
||||
if e == nil || e.config == nil || e.config.CoinSource.SourceType != "vergex_signal" {
|
||||
return result
|
||||
}
|
||||
if e.vergexClient == nil {
|
||||
logger.Warnf("⚠️ Vergex signal data skipped: claw402 wallet is not configured")
|
||||
return result
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
source := e.config.CoinSource
|
||||
marketType := source.VergexMarketType
|
||||
if marketType == "" {
|
||||
marketType = vergex.DefaultMarketType
|
||||
}
|
||||
chain := source.VergexChain
|
||||
chain = vergex.QueryChain(chain)
|
||||
|
||||
seen := make(map[string]bool)
|
||||
limited := make([]string, 0, store.MaxCandidateCoins)
|
||||
for _, symbol := range symbols {
|
||||
symbol = vergexDetailSymbolForLookup(marketType, symbol)
|
||||
if symbol == "" {
|
||||
continue
|
||||
}
|
||||
if seen[symbol] {
|
||||
continue
|
||||
}
|
||||
seen[symbol] = true
|
||||
limited = append(limited, symbol)
|
||||
if len(limited) >= store.MaxCandidateCoins+store.MaxPositions {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
type vergexAnalysisResult struct {
|
||||
symbol string
|
||||
analysis *vergex.MarketAnalysis
|
||||
}
|
||||
|
||||
resultCh := make(chan vergexAnalysisResult, len(limited))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, vergexDetailSymbolConcurrency)
|
||||
for _, symbol := range limited {
|
||||
symbol := symbol
|
||||
querySymbol := vergex.QuerySymbol(symbol)
|
||||
if querySymbol == "" {
|
||||
continue
|
||||
}
|
||||
itemMarketType := marketType
|
||||
itemCategory := ""
|
||||
var ranking *vergex.SignalRankItem
|
||||
if cached, ok := e.vergexRankingCache[symbol]; ok && cached != nil {
|
||||
ranking = cached
|
||||
if cached.MarketType != "" {
|
||||
itemMarketType = cached.MarketType
|
||||
}
|
||||
itemCategory = cached.Category
|
||||
}
|
||||
|
||||
analysis := &vergex.MarketAnalysis{
|
||||
Symbol: symbol,
|
||||
QuerySymbol: querySymbol,
|
||||
MarketType: itemMarketType,
|
||||
Ranking: ranking,
|
||||
}
|
||||
query := vergex.Query{
|
||||
MarketType: itemMarketType,
|
||||
Symbol: symbol,
|
||||
Chain: chain,
|
||||
LiqBand: source.VergexLiqBand,
|
||||
Category: itemCategory,
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
analysis.SignalLabError = ctx.Err().Error()
|
||||
analysis.HeatmapError = ctx.Err().Error()
|
||||
resultCh <- vergexAnalysisResult{symbol: symbol, analysis: analysis}
|
||||
return
|
||||
}
|
||||
e.populateVergexDetailData(ctx, analysis, query)
|
||||
if len(analysis.SignalLab) > 0 || len(analysis.Heatmap) > 0 ||
|
||||
analysis.SignalLabError != "" || analysis.HeatmapError != "" || analysis.Ranking != nil {
|
||||
resultCh <- vergexAnalysisResult{symbol: symbol, analysis: analysis}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(resultCh)
|
||||
for item := range resultCh {
|
||||
result[item.symbol] = item.analysis
|
||||
}
|
||||
|
||||
logger.Infof("📊 Vergex detail data ready for %d symbols", len(result))
|
||||
return result
|
||||
}
|
||||
|
||||
func vergexDetailSymbolForLookup(marketType, symbol string) string {
|
||||
return vergex.TradableSymbolForMarket(marketType, symbol)
|
||||
}
|
||||
|
||||
const (
|
||||
vergexDetailRequestTimeout = 45 * time.Second
|
||||
vergexDetailSymbolConcurrency = 2
|
||||
)
|
||||
|
||||
func (e *StrategyEngine) populateVergexDetailData(ctx context.Context, analysis *vergex.MarketAnalysis, query vergex.Query) {
|
||||
type endpointResult struct {
|
||||
name string
|
||||
body json.RawMessage
|
||||
err error
|
||||
}
|
||||
|
||||
run := func(name string, fetch func(context.Context, vergex.Query) (json.RawMessage, error), out chan<- endpointResult) {
|
||||
requestCtx, cancel := context.WithTimeout(ctx, vergexDetailRequestTimeout)
|
||||
defer cancel()
|
||||
body, err := fetch(requestCtx, query)
|
||||
out <- endpointResult{name: name, body: body, err: err}
|
||||
}
|
||||
|
||||
out := make(chan endpointResult, 2)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
run("signal-lab", e.fetchVergexSignalLabWithFallback, out)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
run("heatmap", e.fetchVergexHeatmapWithFallback, out)
|
||||
}()
|
||||
wg.Wait()
|
||||
close(out)
|
||||
|
||||
for item := range out {
|
||||
switch item.name {
|
||||
case "signal-lab":
|
||||
if item.err != nil {
|
||||
logger.Warnf("⚠️ Failed to fetch Vergex signal-lab for %s: %v", analysis.Symbol, item.err)
|
||||
analysis.SignalLabError = item.err.Error()
|
||||
} else {
|
||||
analysis.SignalLab = item.body
|
||||
}
|
||||
case "heatmap":
|
||||
if item.err != nil {
|
||||
logger.Warnf("⚠️ Failed to fetch Vergex heatmap for %s: %v", analysis.Symbol, item.err)
|
||||
analysis.HeatmapError = item.err.Error()
|
||||
} else {
|
||||
analysis.Heatmap = item.body
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) fetchVergexSignalLabWithFallback(ctx context.Context, query vergex.Query) (json.RawMessage, error) {
|
||||
var lastErr error
|
||||
for idx, candidate := range vergexDetailQueryCandidates(query) {
|
||||
body, err := e.vergexClient.GetSignalLab(ctx, candidate)
|
||||
if err == nil {
|
||||
if idx > 0 {
|
||||
logger.Infof("✅ Vergex signal-lab succeeded with fallback marketType=%s chain=%s", candidate.MarketType, withDefaultText(candidate.Chain, "default"))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !isRetryableVergexDetailError(err) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) fetchVergexHeatmapWithFallback(ctx context.Context, query vergex.Query) (json.RawMessage, error) {
|
||||
var lastErr error
|
||||
for idx, candidate := range vergexDetailQueryCandidates(query) {
|
||||
body, err := e.vergexClient.GetCostLiquidationHeatmap(ctx, candidate)
|
||||
if err == nil {
|
||||
if idx > 0 {
|
||||
logger.Infof("✅ Vergex heatmap succeeded with fallback marketType=%s chain=%s", candidate.MarketType, withDefaultText(candidate.Chain, "default"))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !isRetryableVergexDetailError(err) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func vergexDetailQueryCandidates(query vergex.Query) []vergex.Query {
|
||||
marketTypes := vergexDetailMarketTypeCandidates(query)
|
||||
chains := uniqueValues(query.Chain, "mainnet", "")
|
||||
|
||||
candidates := make([]vergex.Query, 0, len(marketTypes)*len(chains))
|
||||
for _, marketType := range marketTypes {
|
||||
for _, chain := range chains {
|
||||
candidate := query
|
||||
candidate.MarketType = marketType
|
||||
candidate.Chain = chain
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func vergexDetailMarketTypeCandidates(query vergex.Query) []string {
|
||||
if isVergexAllMarketType(query.MarketType) {
|
||||
if market.IsXyzDexAsset(query.Symbol) {
|
||||
return uniqueNonEmpty(vergex.DefaultMarketType, "hip3-perp", "hip3Perp", "core_perp")
|
||||
}
|
||||
return uniqueNonEmpty("core_perp", vergex.DefaultMarketType, "hip3-perp", "hip3Perp")
|
||||
}
|
||||
values := []string{query.MarketType, vergex.DefaultMarketType, "hip3-perp", "hip3Perp", "core_perp"}
|
||||
return uniqueNonEmpty(values...)
|
||||
}
|
||||
|
||||
func isVergexAllMarketType(marketType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(marketType)) {
|
||||
case "", "all", "any", "ranking", "signal-ranking", "signal_ranking", "claw402", "vergex":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isRetryableVergexDetailError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "invalid markettype") ||
|
||||
strings.Contains(msg, "invalid_request") ||
|
||||
strings.Contains(msg, "invalid chain") ||
|
||||
strings.Contains(msg, "market not found") ||
|
||||
strings.Contains(msg, "not_found")
|
||||
}
|
||||
|
||||
func uniqueNonEmpty(values ...string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func uniqueValues(values ...string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FetchOIRankingData fetches market-wide OI ranking data
|
||||
func (e *StrategyEngine) FetchOIRankingData() *nofxos.OIRankingData {
|
||||
indicators := e.config.Indicators
|
||||
if !indicators.EnableOIRanking {
|
||||
return nil
|
||||
}
|
||||
if e.usesHyperliquidNativeUniverse() {
|
||||
logger.Infof("⏭️ Skipping NofxOS OI ranking for Hyperliquid strategy; native Hyperliquid universe is the source of truth")
|
||||
return nil
|
||||
}
|
||||
|
||||
duration := indicators.OIRankingDuration
|
||||
if duration == "" {
|
||||
@@ -804,6 +1436,10 @@ func (e *StrategyEngine) FetchNetFlowRankingData() *nofxos.NetFlowRankingData {
|
||||
if !indicators.EnableNetFlowRanking {
|
||||
return nil
|
||||
}
|
||||
if e.usesHyperliquidNativeUniverse() {
|
||||
logger.Infof("⏭️ Skipping NofxOS netflow ranking for Hyperliquid strategy; native Hyperliquid universe is the source of truth")
|
||||
return nil
|
||||
}
|
||||
|
||||
duration := indicators.NetFlowRankingDuration
|
||||
if duration == "" {
|
||||
@@ -836,6 +1472,10 @@ func (e *StrategyEngine) FetchPriceRankingData() *nofxos.PriceRankingData {
|
||||
if !indicators.EnablePriceRanking {
|
||||
return nil
|
||||
}
|
||||
if e.usesHyperliquidNativeUniverse() {
|
||||
logger.Infof("⏭️ Skipping NofxOS price ranking for Hyperliquid strategy; native Hyperliquid universe is the source of truth")
|
||||
return nil
|
||||
}
|
||||
|
||||
durations := indicators.PriceRankingDuration
|
||||
if durations == "" {
|
||||
|
||||
@@ -84,6 +84,8 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
|
||||
return nil, fmt.Errorf("failed to fetch market data: %w", err)
|
||||
}
|
||||
}
|
||||
pruneCandidateCoinsWithoutMarketData(ctx)
|
||||
enrichVergexDataWithStrategy(ctx, engine)
|
||||
|
||||
// Ensure OITopDataMap is initialized
|
||||
if ctx.OITopDataMap == nil {
|
||||
@@ -141,6 +143,30 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func enrichVergexDataWithStrategy(ctx *Context, engine *StrategyEngine) {
|
||||
if ctx == nil || engine == nil || ctx.VergexDataMap != nil {
|
||||
return
|
||||
}
|
||||
if engine.GetConfig().CoinSource.SourceType != "vergex_signal" {
|
||||
return
|
||||
}
|
||||
symbolSet := make(map[string]bool)
|
||||
symbols := make([]string, 0, len(ctx.CandidateCoins)+len(ctx.Positions))
|
||||
for _, coin := range ctx.CandidateCoins {
|
||||
if !symbolSet[coin.Symbol] {
|
||||
symbolSet[coin.Symbol] = true
|
||||
symbols = append(symbols, coin.Symbol)
|
||||
}
|
||||
}
|
||||
for _, pos := range ctx.Positions {
|
||||
if !symbolSet[pos.Symbol] {
|
||||
symbolSet[pos.Symbol] = true
|
||||
symbols = append(symbols, pos.Symbol)
|
||||
}
|
||||
}
|
||||
ctx.VergexDataMap = engine.FetchVergexDataBatch(nil, symbols)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Market Data Fetching
|
||||
// ============================================================================
|
||||
@@ -223,6 +249,21 @@ func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func pruneCandidateCoinsWithoutMarketData(ctx *Context) {
|
||||
if ctx == nil || len(ctx.CandidateCoins) == 0 || len(ctx.MarketDataMap) == 0 {
|
||||
return
|
||||
}
|
||||
kept := make([]CandidateCoin, 0, len(ctx.CandidateCoins))
|
||||
for _, coin := range ctx.CandidateCoins {
|
||||
if _, ok := ctx.MarketDataMap[coin.Symbol]; ok {
|
||||
kept = append(kept, coin)
|
||||
continue
|
||||
}
|
||||
logger.Infof("⚠️ Skipping candidate %s in AI prompt: no valid market/K-line data", coin.Symbol)
|
||||
}
|
||||
ctx.CandidateCoins = kept
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI Response Parsing
|
||||
// ============================================================================
|
||||
|
||||
42
kernel/engine_directional_test.go
Normal file
42
kernel/engine_directional_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"nofx/provider/vergex"
|
||||
)
|
||||
|
||||
func TestDirectionalCandidates(t *testing.T) {
|
||||
e := &StrategyEngine{
|
||||
vergexRankingCache: map[string]*vergex.SignalRankItem{
|
||||
"xyz:NVDA": {Symbol: "xyz:NVDA", Bias: "bullish", Rank: 2, Score: 1.07},
|
||||
"xyz:AAPL": {Symbol: "xyz:AAPL", Bias: "bullish", Rank: 1, Score: 1.76},
|
||||
"BTC": {Symbol: "BTC", Bias: "bearish", Rank: 3, Score: -0.9},
|
||||
"ETH": {Symbol: "ETH", Bias: "bearish", Rank: 1, Score: -0.05},
|
||||
"SOL": {Symbol: "SOL", Bias: "neutral", Rank: 1, Score: 0.4},
|
||||
},
|
||||
}
|
||||
|
||||
bull, bear := e.DirectionalCandidates()
|
||||
|
||||
if len(bull) != 2 || bull[0].Symbol != "xyz:AAPL" || bull[1].Symbol != "xyz:NVDA" {
|
||||
t.Fatalf("bullish should be rank-ordered [xyz:AAPL xyz:NVDA], got %v", bull)
|
||||
}
|
||||
if bull[0].Score != 1.76 || bull[1].Score != 1.07 {
|
||||
t.Fatalf("bullish candidates should carry their board scores, got %v", bull)
|
||||
}
|
||||
if len(bear) != 2 || bear[0].Symbol != "ETH" || bear[1].Symbol != "BTC" {
|
||||
t.Fatalf("bearish should be rank-ordered [ETH BTC], got %v", bear)
|
||||
}
|
||||
if bear[0].Score != -0.05 || bear[1].Score != -0.9 {
|
||||
t.Fatalf("bearish candidates should carry their board scores, got %v", bear)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectionalCandidatesEmpty(t *testing.T) {
|
||||
e := &StrategyEngine{}
|
||||
bull, bear := e.DirectionalCandidates()
|
||||
if len(bull) != 0 || len(bear) != 0 {
|
||||
t.Fatalf("empty cache should yield no candidates, got %v %v", bull, bear)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package kernel
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
@@ -33,10 +34,18 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi
|
||||
}
|
||||
|
||||
if d.Action == "open_long" || d.Action == "open_short" {
|
||||
// Asset tiering for validation:
|
||||
// - BTC/ETH crypto perps use the BTC/ETH tier (typically 5x equity).
|
||||
// - Hyperliquid XYZ assets (US equities, commodities, forex) are
|
||||
// also treated as the higher tier — they are not crypto altcoins
|
||||
// and the user's quick-trade flow shows them at the higher cap,
|
||||
// so the validator must match.
|
||||
// - Everything else is altcoin (1x equity by default).
|
||||
maxLeverage := altcoinLeverage
|
||||
posRatio := altcoinPosRatio
|
||||
maxPositionValue := accountEquity * posRatio
|
||||
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
|
||||
isMajor := d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" || market.IsXyzDexAsset(d.Symbol)
|
||||
if isMajor {
|
||||
maxLeverage = btcEthLeverage
|
||||
posRatio = btcEthPosRatio
|
||||
maxPositionValue = accountEquity * posRatio
|
||||
@@ -69,9 +78,12 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi
|
||||
|
||||
tolerance := maxPositionValue * 0.01
|
||||
if d.PositionSizeUSD > maxPositionValue+tolerance {
|
||||
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
|
||||
switch {
|
||||
case d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT":
|
||||
return fmt.Errorf("BTC/ETH single coin position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", maxPositionValue, posRatio, d.PositionSizeUSD)
|
||||
} else {
|
||||
case market.IsXyzDexAsset(d.Symbol):
|
||||
return fmt.Errorf("%s position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", d.Symbol, maxPositionValue, posRatio, d.PositionSizeUSD)
|
||||
default:
|
||||
return fmt.Errorf("altcoin single coin position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", maxPositionValue, posRatio, d.PositionSizeUSD)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"nofx/market"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/provider/vergex"
|
||||
"nofx/store"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -18,34 +19,53 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
|
||||
var sb strings.Builder
|
||||
riskControl := e.config.RiskControl
|
||||
promptSections := e.config.PromptSections
|
||||
// System prompts are intentionally English-only. UI copy can be localized,
|
||||
// but the model contract should stay language-stable for an international
|
||||
// open-source project and for reproducible trading behavior.
|
||||
lang := LangEnglish
|
||||
zh := false
|
||||
singleSymbol, primarySymbol := e.singleSymbolInfo()
|
||||
|
||||
// Configs created in the Chinese-UI era carry legacy stored prompt sections
|
||||
// and custom prompts written for a different contract; ignore them wholesale
|
||||
// and fall back to the canonical built-in English sections.
|
||||
legacyZhConfig := strings.EqualFold(strings.TrimSpace(e.config.Language), "zh")
|
||||
if legacyZhConfig {
|
||||
promptSections = store.PromptSectionsConfig{}
|
||||
}
|
||||
|
||||
if e.usesVergexSignalPrompt() {
|
||||
return e.buildVergexSystemPrompt(accountEquity, variant, lang, zh, singleSymbol, primarySymbol)
|
||||
}
|
||||
|
||||
// 0. Data Dictionary & Schema (ensure AI understands all fields)
|
||||
lang := e.GetLanguage()
|
||||
schemaPrompt := GetSchemaPrompt(lang)
|
||||
sb.WriteString(schemaPrompt)
|
||||
sb.WriteString(GetSchemaPrompt(lang))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString("---\n\n")
|
||||
|
||||
// 1. Role definition (editable)
|
||||
if promptSections.RoleDefinition != "" {
|
||||
sb.WriteString(promptSections.RoleDefinition)
|
||||
// 1. Role definition (editable; falls back to a generic intro in the
|
||||
// correct language so we don't mix EN headings with ZH custom text).
|
||||
roleDefinition := englishOnlyPromptSection(promptSections.RoleDefinition)
|
||||
if roleDefinition != "" {
|
||||
sb.WriteString(roleDefinition)
|
||||
sb.WriteString("\n\n")
|
||||
} else if zh {
|
||||
sb.WriteString("# You are a professional Hyperliquid USDC multi-asset trading AI\n\n")
|
||||
sb.WriteString("Your task is to make trading decisions based on the provided market data.\n\n")
|
||||
} else {
|
||||
sb.WriteString("# You are a professional cryptocurrency trading AI\n\n")
|
||||
sb.WriteString("Your task is to make trading decisions based on provided market data.\n\n")
|
||||
sb.WriteString("# You are a professional Hyperliquid USDC multi-asset trading AI\n\n")
|
||||
sb.WriteString("Your task is to make trading decisions based on the provided market data.\n\n")
|
||||
}
|
||||
|
||||
// 2. Trading mode variant
|
||||
switch strings.ToLower(strings.TrimSpace(variant)) {
|
||||
case "aggressive":
|
||||
sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts, can build positions in batches when confidence ≥ 70\n- Allow higher positions, but must strictly set stop-loss and explain risk-reward ratio\n\n")
|
||||
case "conservative":
|
||||
sb.WriteString("## Mode: Conservative\n- Only open positions when multiple signals resonate\n- Prioritize cash preservation, must pause for multiple periods after consecutive losses\n\n")
|
||||
case "scalping":
|
||||
sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n")
|
||||
}
|
||||
writeModeVariant(&sb, variant, zh)
|
||||
|
||||
// 3. Hard constraints (risk control)
|
||||
// 3. Hard constraints (risk control).
|
||||
//
|
||||
// `singleSymbol` is true for strategies that deliberately trade just one
|
||||
// instrument (the quick-create flow, single-asset templates). For those,
|
||||
// the "BTC/ETH vs Altcoin" two-tier categorization is irrelevant and
|
||||
// actively misleading — we surface a single position-value limit instead.
|
||||
btcEthPosValueRatio := riskControl.BTCETHMaxPositionValueRatio
|
||||
if btcEthPosValueRatio <= 0 {
|
||||
btcEthPosValueRatio = 5.0
|
||||
@@ -55,168 +75,678 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
|
||||
altcoinPosValueRatio = 1.0
|
||||
}
|
||||
|
||||
sb.WriteString("# Hard Constraints (Risk Control)\n\n")
|
||||
sb.WriteString("## CODE ENFORCED (Backend validation, cannot be bypassed):\n")
|
||||
sb.WriteString(fmt.Sprintf("- Max Positions: %d coins simultaneously\n", riskControl.MaxPositions))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (Altcoins): max %.0f USDT (= equity %.0f × %.1fx)\n",
|
||||
accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (BTC/ETH): max %.0f USDT (= equity %.0f × %.1fx)\n",
|
||||
accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Max Margin Usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min Position Size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
|
||||
sb.WriteString("## AI GUIDED (Recommended, you should follow):\n")
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage: Altcoins max %dx | BTC/ETH max %dx\n",
|
||||
riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
|
||||
sb.WriteString(fmt.Sprintf("- Risk-Reward Ratio: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min Confidence: ≥%d to open position\n\n", riskControl.MinConfidence))
|
||||
|
||||
// Position sizing guidance
|
||||
sb.WriteString("## Position Sizing Guidance\n")
|
||||
sb.WriteString("Calculate `position_size_usd` based on your confidence and the Position Value Limits above:\n")
|
||||
sb.WriteString("- High confidence (≥85): Use 80-100%% of max position value limit\n")
|
||||
sb.WriteString("- Medium confidence (70-84): Use 50-80%% of max position value limit\n")
|
||||
sb.WriteString("- Low confidence (60-69): Use 30-50%% of max position value limit\n")
|
||||
sb.WriteString(fmt.Sprintf("- Example: With equity %.0f and BTC/ETH ratio %.1fx, max is %.0f USDT\n",
|
||||
accountEquity, btcEthPosValueRatio, accountEquity*btcEthPosValueRatio))
|
||||
sb.WriteString("- **DO NOT** just use available_balance as position_size_usd. Use the Position Value Limits!\n\n")
|
||||
writeHardConstraints(&sb, accountEquity, riskControl, btcEthPosValueRatio, altcoinPosValueRatio, singleSymbol, primarySymbol, zh)
|
||||
|
||||
// 4. Trading frequency (editable)
|
||||
if promptSections.TradingFrequency != "" {
|
||||
sb.WriteString(promptSections.TradingFrequency)
|
||||
tradingFrequency := englishOnlyPromptSection(promptSections.TradingFrequency)
|
||||
if tradingFrequency != "" {
|
||||
sb.WriteString(tradingFrequency)
|
||||
sb.WriteString("\n\n")
|
||||
} else if zh {
|
||||
sb.WriteString("# ⏱️ Trading Frequency Awareness\n\n")
|
||||
sb.WriteString("- Excellent traders: 2-4 trades/day ≈ 0.1-0.2 trades/hour\n")
|
||||
sb.WriteString("- >2 trades/hour = overtrading\n")
|
||||
sb.WriteString("- Single position hold time ≥ 45-90 minutes\n")
|
||||
sb.WriteString("If you find yourself trading every cycle → standards too low; if closing positions < 45 minutes → too impulsive.\n\n")
|
||||
} else {
|
||||
sb.WriteString("# ⏱️ Trading Frequency Awareness\n\n")
|
||||
sb.WriteString("- Excellent traders: 2-4 trades/day ≈ 0.1-0.2 trades/hour\n")
|
||||
sb.WriteString("- >2 trades/hour = Overtrading\n")
|
||||
sb.WriteString("- Single position hold time ≥ 30-60 minutes\n")
|
||||
sb.WriteString("If you find yourself trading every period → standards too low; if closing positions < 30 minutes → too impatient.\n\n")
|
||||
sb.WriteString("- >2 trades/hour = overtrading\n")
|
||||
sb.WriteString("- Single position hold time ≥ 45-90 minutes\n")
|
||||
sb.WriteString("If you find yourself trading every cycle → standards too low; if closing positions < 45 minutes → too impulsive.\n\n")
|
||||
}
|
||||
|
||||
// 5. Entry standards (editable)
|
||||
if promptSections.EntryStandards != "" {
|
||||
sb.WriteString(promptSections.EntryStandards)
|
||||
entryStandards := englishOnlyPromptSection(promptSections.EntryStandards)
|
||||
if entryStandards != "" {
|
||||
sb.WriteString(entryStandards)
|
||||
if zh {
|
||||
sb.WriteString("\n\nYou have the following indicator data:\n")
|
||||
e.writeAvailableIndicators(&sb)
|
||||
} else {
|
||||
sb.WriteString("\n\nYou have the following indicator data:\n")
|
||||
}
|
||||
e.writeAvailableIndicators(&sb, zh)
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("\n**Confidence ≥ %d** required to open positions.\n\n", riskControl.MinConfidence))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("\n**Confidence ≥ %d** required to open positions.\n\n", riskControl.MinConfidence))
|
||||
}
|
||||
} else if zh {
|
||||
sb.WriteString("# 🎯 Entry Standards (Strict)\n\n")
|
||||
sb.WriteString("Only open positions when multiple signals resonate. You have:\n")
|
||||
e.writeAvailableIndicators(&sb, zh)
|
||||
sb.WriteString(fmt.Sprintf("\nFeel free to use any effective analysis method, but **confidence ≥ %d** is required to open positions; avoid low-quality behaviors such as single-indicator entries, contradictory signals, sideways chop, or re-entering immediately after a close.\n\n", riskControl.MinConfidence))
|
||||
} else {
|
||||
sb.WriteString("# 🎯 Entry Standards (Strict)\n\n")
|
||||
sb.WriteString("Only open positions when multiple signals resonate. You have:\n")
|
||||
e.writeAvailableIndicators(&sb)
|
||||
sb.WriteString(fmt.Sprintf("\nFeel free to use any effective analysis method, but **confidence ≥ %d** required to open positions; avoid low-quality behaviors such as single indicators, contradictory signals, sideways consolidation, reopening immediately after closing, etc.\n\n", riskControl.MinConfidence))
|
||||
e.writeAvailableIndicators(&sb, zh)
|
||||
sb.WriteString(fmt.Sprintf("\nFeel free to use any effective analysis method, but **confidence ≥ %d** is required to open positions; avoid low-quality behaviors such as single-indicator entries, contradictory signals, sideways chop, or re-entering immediately after a close.\n\n", riskControl.MinConfidence))
|
||||
}
|
||||
|
||||
// 6. Decision process (editable)
|
||||
if promptSections.DecisionProcess != "" {
|
||||
sb.WriteString(promptSections.DecisionProcess)
|
||||
decisionProcess := englishOnlyPromptSection(promptSections.DecisionProcess)
|
||||
if decisionProcess != "" {
|
||||
sb.WriteString(decisionProcess)
|
||||
sb.WriteString("\n\n")
|
||||
} else if zh {
|
||||
sb.WriteString("# 📋 Decision Process\n\n")
|
||||
sb.WriteString("1. Check positions → take profit / stop loss?\n")
|
||||
sb.WriteString("2. Scan candidates + multi-timeframe → are there strong signals?\n")
|
||||
sb.WriteString("3. Write chain of thought first, then output structured JSON\n\n")
|
||||
} else {
|
||||
sb.WriteString("# 📋 Decision Process\n\n")
|
||||
sb.WriteString("1. Check positions → Should we take profit/stop-loss\n")
|
||||
sb.WriteString("2. Scan candidate coins + multi-timeframe → Are there strong signals\n")
|
||||
sb.WriteString("1. Check positions → take profit / stop loss?\n")
|
||||
sb.WriteString("2. Scan candidates + multi-timeframe → are there strong signals?\n")
|
||||
sb.WriteString("3. Write chain of thought first, then output structured JSON\n\n")
|
||||
}
|
||||
|
||||
// 7. Output format
|
||||
sb.WriteString("# Output Format (Strictly Follow)\n\n")
|
||||
sb.WriteString("**Must use XML tags <reasoning> and <decision> to separate chain of thought and decision JSON, avoiding parsing errors**\n\n")
|
||||
sb.WriteString("## Format Requirements\n\n")
|
||||
sb.WriteString("<reasoning>\n")
|
||||
sb.WriteString("Your chain of thought analysis...\n")
|
||||
sb.WriteString("- Briefly analyze your thinking process \n")
|
||||
sb.WriteString("</reasoning>\n\n")
|
||||
sb.WriteString("<decision>\n")
|
||||
sb.WriteString("Step 2: JSON decision array\n\n")
|
||||
sb.WriteString("```json\n[\n")
|
||||
// Use the actual configured position value ratio for BTC/ETH in the example
|
||||
examplePositionSize := accountEquity * btcEthPosValueRatio
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n",
|
||||
riskControl.BTCETHMaxLeverage, examplePositionSize))
|
||||
sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n")
|
||||
sb.WriteString("]\n```\n")
|
||||
sb.WriteString("</decision>\n\n")
|
||||
sb.WriteString("## Field Description\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (opening recommended ≥ %d)\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- **IMPORTANT**: All numeric values must be calculated numbers, NOT formulas/expressions (e.g., use `27.76` not `3000 * 0.01`)\n\n")
|
||||
// 7. Output format — schema spec stays in English (this is a parser
|
||||
// contract; reasoning copy is localized below).
|
||||
writeOutputFormat(&sb, accountEquity, btcEthPosValueRatio, riskControl, singleSymbol, primarySymbol, zh)
|
||||
|
||||
// 8. Custom Prompt
|
||||
if e.config.CustomPrompt != "" {
|
||||
// 8. Custom Prompt.
|
||||
//
|
||||
// For single-symbol Hyperliquid XYZ assets (US equities, commodities,
|
||||
// forex), we replace any stored CustomPrompt with a built-in English
|
||||
// stock-trader template. This serves two purposes:
|
||||
// 1. The auto-generated CustomPrompt from the quick-create flow used
|
||||
// to be Chinese (matching UI language), which produced an
|
||||
// incoherent mixed-language final prompt that confused the LLM.
|
||||
// 2. It guarantees a stock-specific, US-equity-tuned briefing
|
||||
// regardless of when the strategy was first created.
|
||||
customPrompt := englishOnlyPromptSection(e.config.CustomPrompt)
|
||||
if legacyZhConfig {
|
||||
customPrompt = ""
|
||||
}
|
||||
if singleSymbol && market.IsXyzDexAsset(primarySymbol) {
|
||||
customPrompt = buildXYZStockCustomPrompt(primarySymbol)
|
||||
}
|
||||
|
||||
if customPrompt != "" {
|
||||
if zh {
|
||||
sb.WriteString("# 📌 Personalized Trading Strategy\n\n")
|
||||
sb.WriteString(e.config.CustomPrompt)
|
||||
} else {
|
||||
sb.WriteString("# 📌 Personalized Trading Strategy\n\n")
|
||||
}
|
||||
sb.WriteString(customPrompt)
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString("Note: The above personalized strategy is a supplement to the basic rules and cannot violate the basic risk control principles.\n")
|
||||
if zh {
|
||||
sb.WriteString("Note: the above personalized strategy supplements the basic rules and may not violate the core risk controls.\n")
|
||||
} else {
|
||||
sb.WriteString("Note: the above personalized strategy supplements the basic rules and may not violate the core risk controls.\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) {
|
||||
func (e *StrategyEngine) usesVergexSignalPrompt() bool {
|
||||
if e == nil || e.config == nil {
|
||||
return false
|
||||
}
|
||||
coinSource := e.config.CoinSource
|
||||
sourceType := strings.ToLower(strings.TrimSpace(coinSource.SourceType))
|
||||
return sourceType == "vergex_signal" ||
|
||||
sourceType == "claw402" ||
|
||||
sourceType == "claw402_vergex" ||
|
||||
coinSource.VergexMarketType != "" ||
|
||||
coinSource.VergexChain != "" ||
|
||||
coinSource.VergexLimit > 0
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant string, lang Language, zh bool, singleSymbol bool, primarySymbol string) string {
|
||||
var sb strings.Builder
|
||||
riskControl := e.config.RiskControl
|
||||
|
||||
writeVergexSchemaPrompt(&sb, zh)
|
||||
sb.WriteString("\n\n---\n\n")
|
||||
|
||||
if zh {
|
||||
sb.WriteString("# You are the NOFX Claw402 auto-trader\n\n")
|
||||
sb.WriteString("Trade only Hyperliquid instruments returned by this cycle's Claw402.ai/Vergex board. You may trade only the current candidate symbols and existing positions; never invent tickers or rotate outside the provided universe.\n\n")
|
||||
sb.WriteString("# Decision Data Priority\n\n")
|
||||
sb.WriteString("1. Claw402.ai Signal Ranking: candidate pool, rank, direction and category.\n")
|
||||
sb.WriteString("2. Claw402.ai Signal Lab: trend, momentum, event/model confirmation; this is the core pre-entry confirmation source.\n")
|
||||
sb.WriteString("3. Claw402.ai Cost/Liquidation Heatmap: crowded liquidation/cost zones, stop placement and target zones.\n")
|
||||
sb.WriteString("4. Raw OHLCV candles: entry timing, trend structure, volatility and risk/reward validation.\n\n")
|
||||
sb.WriteString("# Trading Rules\n\n")
|
||||
sb.WriteString("- Manage existing positions before opening new ones.\n")
|
||||
sb.WriteString("- Open only when Signal Lab, heatmap and raw candles broadly agree; wait when key data is missing or contradictory.\n")
|
||||
sb.WriteString("- Ranking alone is not an entry reason; it only defines the candidate pool.\n")
|
||||
sb.WriteString("- Every symbol in Candidate Coins is part of the allowed trading universe; missing detail can lower confidence or trigger waiting, but does not make the symbol non-tradable.\n")
|
||||
sb.WriteString("- If Signal Lab or heatmap is absent from that symbol's Vergex Claw402 Signals, state it in reasoning; if it is present, never claim the symbol lacks that data.\n")
|
||||
sb.WriteString("- Hold for BIG moves, do not churn: hold new positions for at least 4 hours; never close inside the -4%% to +6%% noise band before ~8 hours; after closing a symbol wait 3 hours before re-entry; open at most 1-2 new positions per hour. Small in-and-out trades bled this account to death on fees.\n")
|
||||
sb.WriteString("- Fees are the main edge killer: a round trip costs ~0.1%% of notional. Only enter setups where the realistic target is a LARGE move: stop-loss around -5%% and take-profit around +10-12%% (roughly 2:1 or better). Do not aim for 0.5%% scalps — they cannot cover fees. Fewer, high-conviction, wide-target, multi-hour holds only.\n")
|
||||
sb.WriteString("- Set WIDE stops and targets: place the stop well beyond short-term noise (around -5%%) and the target at a distant heatmap resistance/liquidation zone (around +10-12%%). Give the position room to develop; do not exit on small green or small red.\n\n")
|
||||
} else {
|
||||
sb.WriteString("# You are the NOFX Claw402 auto-trader\n\n")
|
||||
sb.WriteString("Trade only Hyperliquid instruments returned by this cycle's Claw402.ai/Vergex board. You may trade only the current candidate symbols and existing positions; never invent tickers or rotate outside the provided universe.\n\n")
|
||||
sb.WriteString("# Decision Data Priority\n\n")
|
||||
sb.WriteString("1. Claw402.ai Signal Ranking: candidate pool, rank, direction and category.\n")
|
||||
sb.WriteString("2. Claw402.ai Signal Lab: trend, momentum, event/model confirmation; this is the core pre-entry confirmation source.\n")
|
||||
sb.WriteString("3. Claw402.ai Cost/Liquidation Heatmap: crowded liquidation/cost zones, stop placement and target zones.\n")
|
||||
sb.WriteString("4. Raw OHLCV candles: entry timing, trend structure, volatility and risk/reward validation.\n\n")
|
||||
sb.WriteString("# Trading Rules\n\n")
|
||||
sb.WriteString("- Manage existing positions before opening new ones.\n")
|
||||
sb.WriteString("- Open only when Signal Lab, heatmap and raw candles broadly agree; wait when key data is missing or contradictory.\n")
|
||||
sb.WriteString("- Ranking alone is not an entry reason; it only defines the candidate pool.\n")
|
||||
sb.WriteString("- Every symbol in Candidate Coins is part of the allowed trading universe; missing detail can lower confidence or trigger waiting, but does not make the symbol non-tradable.\n")
|
||||
sb.WriteString("- If Signal Lab or heatmap is absent from that symbol's Vergex Claw402 Signals, state it in reasoning; if it is present, never claim the symbol lacks that data.\n")
|
||||
sb.WriteString("- Hold for BIG moves, do not churn: hold new positions for at least 4 hours; never close inside the -4%% to +6%% noise band before ~8 hours; after closing a symbol wait 3 hours before re-entry; open at most 1-2 new positions per hour. Small in-and-out trades bled this account to death on fees.\n")
|
||||
sb.WriteString("- Fees are the main edge killer: a round trip costs ~0.1%% of notional. Only enter setups where the realistic target is a LARGE move: stop-loss around -5%% and take-profit around +10-12%% (roughly 2:1 or better). Do not aim for 0.5%% scalps — they cannot cover fees. Fewer, high-conviction, wide-target, multi-hour holds only.\n")
|
||||
sb.WriteString("- Set WIDE stops and targets: place the stop well beyond short-term noise (around -5%%) and the target at a distant heatmap resistance/liquidation zone (around +10-12%%). Give the position room to develop; do not exit on small green or small red.\n\n")
|
||||
}
|
||||
|
||||
writeModeVariant(&sb, variant, zh)
|
||||
|
||||
altcoinPosValueRatio := riskControl.AltcoinMaxPositionValueRatio
|
||||
if altcoinPosValueRatio <= 0 {
|
||||
altcoinPosValueRatio = 1.0
|
||||
}
|
||||
writeVergexHardConstraints(&sb, accountEquity, riskControl, altcoinPosValueRatio, zh)
|
||||
writeVergexOutputFormat(&sb, accountEquity, riskControl, altcoinPosValueRatio, singleSymbol, primarySymbol, zh)
|
||||
|
||||
customPrompt := vergexCustomPromptSection(e.config.CustomPrompt)
|
||||
if customPrompt != "" {
|
||||
sb.WriteString("# User Preference\n\n")
|
||||
sb.WriteString(customPrompt)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// vergexCustomPromptSection returns the user's custom prompt for the vergex
|
||||
// path, dropping legacy directional overrides ("long only" era) that would
|
||||
// contradict the data-driven direction rule baked into this prompt.
|
||||
func vergexCustomPromptSection(section string) string {
|
||||
trimmed := englishOnlyPromptSection(section)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
legacyDirectives := []string{
|
||||
"long only",
|
||||
"long-only",
|
||||
"do not short",
|
||||
"no shorts",
|
||||
"must open a long",
|
||||
"short only",
|
||||
"short-only",
|
||||
}
|
||||
for _, directive := range legacyDirectives {
|
||||
if strings.Contains(lower, directive) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func englishOnlyPromptSection(section string) string {
|
||||
trimmed := strings.TrimSpace(section)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if detectLanguage(trimmed) == LangChinese {
|
||||
return ""
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func writeVergexSchemaPrompt(sb *strings.Builder, zh bool) {
|
||||
if zh {
|
||||
sb.WriteString("# Claw402.ai TradeFi Data Guide\n\n")
|
||||
sb.WriteString("- Equity: total account value including unrealized PnL, in USDT.\n")
|
||||
sb.WriteString("- Balance: available balance for new positions, in USDT.\n")
|
||||
sb.WriteString("- Margin: current margin usage; higher means more risk.\n")
|
||||
sb.WriteString("- Position: current holdings with side, entry, leverage, unrealized PnL and liquidation price.\n")
|
||||
sb.WriteString("- Claw402 Ranking: tradable candidate pool, rank, direction and category for this cycle.\n")
|
||||
sb.WriteString("- Signal Lab: per-symbol Claw402 deep signal used to confirm trend and quality.\n")
|
||||
sb.WriteString("- Cost/Liquidation Heatmap: cost and liquidation clusters used for stops, targets and crowding risk.\n")
|
||||
sb.WriteString("- Raw OHLCV Kline: raw candles used for trend structure, entry timing and risk/reward.\n")
|
||||
} else {
|
||||
sb.WriteString("# Claw402.ai TradeFi Data Guide\n\n")
|
||||
sb.WriteString("- Equity: total account value including unrealized PnL, in USDT.\n")
|
||||
sb.WriteString("- Balance: available balance for new positions, in USDT.\n")
|
||||
sb.WriteString("- Margin: current margin usage; higher means more risk.\n")
|
||||
sb.WriteString("- Position: current holdings with side, entry, leverage, unrealized PnL and liquidation price.\n")
|
||||
sb.WriteString("- Claw402 Ranking: tradable candidate pool, rank, direction and category for this cycle.\n")
|
||||
sb.WriteString("- Signal Lab: per-symbol Claw402 deep signal used to confirm trend and quality.\n")
|
||||
sb.WriteString("- Cost/Liquidation Heatmap: cost and liquidation clusters used for stops, targets and crowding risk.\n")
|
||||
sb.WriteString("- Raw OHLCV Kline: raw candles used for trend structure, entry timing and risk/reward.\n")
|
||||
}
|
||||
}
|
||||
|
||||
func writeVergexHardConstraints(sb *strings.Builder, accountEquity float64, riskControl store.RiskControlConfig, tradeFiPositionValueRatio float64, zh bool) {
|
||||
maxPositionValue := accountEquity * tradeFiPositionValueRatio
|
||||
if zh {
|
||||
sb.WriteString("# Hard Risk Constraints\n\n")
|
||||
sb.WriteString("## Backend enforced\n")
|
||||
sb.WriteString(fmt.Sprintf("- Max positions: %d Claw402 candidate instruments at the same time\n", riskControl.MaxPositions))
|
||||
sb.WriteString(fmt.Sprintf("- Max notional per position: %.0f USDT (= equity %.0f × %.1fx)\n", maxPositionValue, accountEquity, tradeFiPositionValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Max margin usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min order size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
sb.WriteString("## AI guided\n")
|
||||
sb.WriteString(fmt.Sprintf("- Leverage: every open position must use exactly %dx\n", riskControl.AltcoinMaxLeverage))
|
||||
sb.WriteString(fmt.Sprintf("- Risk/reward: ≥1:%.1f\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min confidence to open: ≥%d\n\n", riskControl.MinConfidence))
|
||||
sb.WriteString("# Position Sizing\n\n")
|
||||
sb.WriteString("For every `open_long` or `open_short`, use the full max notional per position.\n")
|
||||
sb.WriteString("- Do not scale position_size_usd down by confidence.\n")
|
||||
sb.WriteString("- Do not open small probe positions.\n")
|
||||
sb.WriteString("- If the setup is not strong enough for full size, output `wait`.\n")
|
||||
sb.WriteString("- Do not use available_balance directly as position_size_usd.\n\n")
|
||||
} else {
|
||||
sb.WriteString("# Hard Risk Constraints\n\n")
|
||||
sb.WriteString("## Backend enforced\n")
|
||||
sb.WriteString(fmt.Sprintf("- Max positions: %d Claw402 candidate instruments at the same time\n", riskControl.MaxPositions))
|
||||
sb.WriteString(fmt.Sprintf("- Max notional per position: %.0f USDT (= equity %.0f × %.1fx)\n", maxPositionValue, accountEquity, tradeFiPositionValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Max margin usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min order size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
sb.WriteString("## AI guided\n")
|
||||
sb.WriteString(fmt.Sprintf("- Leverage: every open position must use exactly %dx\n", riskControl.AltcoinMaxLeverage))
|
||||
sb.WriteString(fmt.Sprintf("- Risk/reward: ≥1:%.1f\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min confidence to open: ≥%d\n\n", riskControl.MinConfidence))
|
||||
sb.WriteString("# Position Sizing\n\n")
|
||||
sb.WriteString("For every `open_long` or `open_short`, use the full max notional per position.\n")
|
||||
sb.WriteString("- Do not scale position_size_usd down by confidence.\n")
|
||||
sb.WriteString("- Do not open small probe positions.\n")
|
||||
sb.WriteString("- If the setup is not strong enough for full size, output `wait`.\n")
|
||||
sb.WriteString("- Do not use available_balance directly as position_size_usd.\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
func writeVergexOutputFormat(sb *strings.Builder, accountEquity float64, riskControl store.RiskControlConfig, tradeFiPositionValueRatio float64, singleSymbol bool, primarySymbol string, zh bool) {
|
||||
exampleSymbol := "xyz:NVDA"
|
||||
secondSymbol := "xyz:AAPL"
|
||||
if singleSymbol && strings.TrimSpace(primarySymbol) != "" {
|
||||
exampleSymbol = primarySymbol
|
||||
secondSymbol = primarySymbol
|
||||
}
|
||||
positionSize := accountEquity * tradeFiPositionValueRatio
|
||||
leverage := riskControl.AltcoinMaxLeverage
|
||||
if leverage <= 0 {
|
||||
leverage = 1
|
||||
}
|
||||
|
||||
sb.WriteString("# Output Format (Strictly Follow)\n\n")
|
||||
if zh {
|
||||
sb.WriteString("Use XML tags <reasoning> and <decision> to separate concise analysis from the decision JSON.\n\n")
|
||||
sb.WriteString("Direction must be data-driven: use `open_long` for confirmed upside structures and `open_short` for confirmed downside structures; never default to long-only or short-only behavior.\n\n")
|
||||
if !singleSymbol {
|
||||
sb.WriteString("Evaluate both directions every cycle, but enter a side only when its own signals independently justify it. Never open a position just to balance the book — an unbalanced book beats a forced trade.\n\n")
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("Use XML tags <reasoning> and <decision> to separate concise analysis from the decision JSON.\n\n")
|
||||
sb.WriteString("Direction must be data-driven: use `open_long` for confirmed upside structures and `open_short` for confirmed downside structures; never default to long-only or short-only behavior.\n\n")
|
||||
if !singleSymbol {
|
||||
sb.WriteString("Evaluate both directions every cycle, but enter a side only when its own signals independently justify it. Never open a position just to balance the book — an unbalanced book beats a forced trade.\n\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("<reasoning>\n")
|
||||
if zh {
|
||||
sb.WriteString("Briefly state whether Claw402 ranking, Signal Lab, heatmap and candles agree; if data is missing or conflicting, explain why you wait.\n")
|
||||
} else {
|
||||
sb.WriteString("Briefly state whether Claw402 ranking, Signal Lab, heatmap and candles agree; if data is missing or conflicting, explain why you wait.\n")
|
||||
}
|
||||
sb.WriteString("</reasoning>\n\n")
|
||||
sb.WriteString("<decision>\n")
|
||||
sb.WriteString("```json\n[\n")
|
||||
if singleSymbol {
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0}\n", exampleSymbol, leverage, positionSize))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_long\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0},\n", exampleSymbol, leverage, positionSize))
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0}\n", secondSymbol, leverage, positionSize))
|
||||
}
|
||||
sb.WriteString("]\n```\n")
|
||||
sb.WriteString("</decision>\n\n")
|
||||
|
||||
if zh {
|
||||
sb.WriteString("## Field Requirements\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100; recommended ≥ %d to open\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- All numeric values must be calculated numbers, not formulas.\n")
|
||||
if singleSymbol {
|
||||
sb.WriteString(fmt.Sprintf("- This strategy trades only `%s`; JSON symbol must match it exactly.\n", exampleSymbol))
|
||||
} else {
|
||||
sb.WriteString("- JSON symbols must exactly match current candidates or existing positions; keep `xyz:` on XYZ instruments, and do not add `xyz:` or `USDT` to core crypto symbols.\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
} else {
|
||||
sb.WriteString("## Field Requirements\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100; recommended ≥ %d to open\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- All numeric values must be calculated numbers, not formulas.\n")
|
||||
if singleSymbol {
|
||||
sb.WriteString(fmt.Sprintf("- This strategy trades only `%s`; JSON symbol must match it exactly.\n", exampleSymbol))
|
||||
} else {
|
||||
sb.WriteString("- JSON symbols must exactly match current candidates or existing positions; keep `xyz:` on XYZ instruments, and do not add `xyz:` or `USDT` to core crypto symbols.\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// buildXYZStockCustomPrompt returns the canonical English directional stock
|
||||
// briefing the agent uses for single-symbol Hyperliquid USDC perpetuals on
|
||||
// the XYZ board. Symbol is inlined for LLM grounding so it never confuses the
|
||||
// trading instrument.
|
||||
func buildXYZStockCustomPrompt(symbol string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Trade ONLY the Hyperliquid USDC perpetual %s (US equity / xyz board).\n\n", symbol))
|
||||
sb.WriteString("Core stance: DIRECTIONAL, SIGNAL-DRIVEN. You may open long or short; never force a trade when Signal Lab, liquidation structure and candles disagree.\n\n")
|
||||
|
||||
sb.WriteString("## Flat-Account Rule\n")
|
||||
sb.WriteString("If `Current Positions` is None / empty, evaluate both directions from scratch.\n")
|
||||
sb.WriteString("- Use `open_long` only when upside continuation or bullish reversal is confirmed.\n")
|
||||
sb.WriteString("- Use `open_short` only when downside continuation or bearish reversal is confirmed.\n")
|
||||
sb.WriteString("- Use `wait` when neither side meets the minimum confidence and risk/reward threshold.\n")
|
||||
sb.WriteString("- Do not raise confidence just to force an order; confidence must reflect the evidence.\n\n")
|
||||
|
||||
sb.WriteString("## Long Entry Conditions\n")
|
||||
sb.WriteString("- Break of the prior session/intraday high on rising volume.\n")
|
||||
sb.WriteString("- Pullback to a clearly held intraday support (prior swing low, VWAP, EMA20/50) with a bullish reaction bar.\n")
|
||||
sb.WriteString("- Sector tape strength (broad US-equity bid, sympathy with peers in the same theme).\n")
|
||||
sb.WriteString("- Confirmed catalyst: earnings beat, guide up, sector rotation, macro tailwind.\n\n")
|
||||
|
||||
sb.WriteString("## Short Entry Conditions\n")
|
||||
sb.WriteString("- Breakdown below intraday support or value area with expanding volume.\n")
|
||||
sb.WriteString("- Failed breakout, lower high, or bearish rejection at resistance.\n")
|
||||
sb.WriteString("- Signal Lab / liquidation structure shows downside fuel, trapped longs, or weak support below.\n")
|
||||
sb.WriteString("- Negative catalyst: earnings miss, guide down, sector weakness, macro headwind.\n\n")
|
||||
|
||||
sb.WriteString("## Risk Guardrails (non-negotiable)\n")
|
||||
sb.WriteString("- Per-trade stop-loss: 1.5-3% from entry. ALWAYS set a numeric `stop_loss`.\n")
|
||||
sb.WriteString("- Take-profit: target at least R/R 2:1; set a numeric `take_profit`.\n")
|
||||
sb.WriteString("- Per-trade notional: <= 25% of account equity (probing 10-15%, full 20-25%).\n")
|
||||
sb.WriteString("- Leverage: 2-3x default, never above 5x. Never go all-in.\n")
|
||||
sb.WriteString("- Do not flip directly from long to short or short to long in the same cycle. Manage or close the open position first.\n\n")
|
||||
|
||||
sb.WriteString("## Position Management\n")
|
||||
sb.WriteString("- Trail stop to breakeven once +1R, take partial profits at +2R if momentum stalls.\n")
|
||||
sb.WriteString("- Cut quickly if price breaks the stop or the catalyst thesis fails.\n")
|
||||
sb.WriteString("- Holding past 45 minutes is fine; flipping in/out every cycle is not.\n\n")
|
||||
|
||||
sb.WriteString("## Discipline\n")
|
||||
sb.WriteString(fmt.Sprintf("- Single-symbol mandate: never rotate into another ticker. The decision JSON `symbol` MUST be exactly \"%s\".\n", symbol))
|
||||
sb.WriteString("- Before every decision: check current price vs prior pivot, volume vs 5m/1h average, and the broader US-equity tape.\n")
|
||||
sb.WriteString("- If positions are open, prioritize managing them over piling on new ones.")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// singleSymbolInfo returns (true, "ARM-USDC") for static-coin strategies that
|
||||
// trade exactly one instrument. Multi-symbol strategies return (false, "").
|
||||
// The flag is used to drop crypto-specific "BTC/ETH vs Altcoin" labeling and
|
||||
// to put the actual trading symbol into the JSON example.
|
||||
func (e *StrategyEngine) singleSymbolInfo() (bool, string) {
|
||||
coinSource := e.config.CoinSource
|
||||
if (coinSource.SourceType == "static" || coinSource.SourceType == "vergex_signal") && len(coinSource.StaticCoins) == 1 {
|
||||
return true, strings.ToUpper(strings.TrimSpace(coinSource.StaticCoins[0]))
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func writeModeVariant(sb *strings.Builder, variant string, zh bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(variant)) {
|
||||
case "aggressive":
|
||||
if zh {
|
||||
sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts; may scale in when confidence ≥ 70\n- Allow larger positions, but must strictly set stop-loss and explain the risk-reward ratio\n\n")
|
||||
} else {
|
||||
sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts; may scale in when confidence ≥ 70\n- Allow larger positions, but must strictly set stop-loss and explain the risk-reward ratio\n\n")
|
||||
}
|
||||
case "conservative":
|
||||
if zh {
|
||||
sb.WriteString("## Mode: Conservative\n- Open positions only when multiple signals resonate\n- Prioritize capital preservation; pause for multiple periods after consecutive losses\n\n")
|
||||
} else {
|
||||
sb.WriteString("## Mode: Conservative\n- Open positions only when multiple signals resonate\n- Prioritize capital preservation; pause for multiple periods after consecutive losses\n\n")
|
||||
}
|
||||
case "scalping":
|
||||
if zh {
|
||||
sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n")
|
||||
} else {
|
||||
sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeHardConstraints(sb *strings.Builder, accountEquity float64, riskControl store.RiskControlConfig, btcEthPosValueRatio, altcoinPosValueRatio float64, singleSymbol bool, primarySymbol string, zh bool) {
|
||||
if zh {
|
||||
sb.WriteString("# Hard Constraints (Risk Control)\n\n")
|
||||
sb.WriteString("## CODE ENFORCED (backend validation, cannot be bypassed):\n")
|
||||
sb.WriteString(fmt.Sprintf("- Max Positions: %d instruments simultaneously\n", riskControl.MaxPositions))
|
||||
} else {
|
||||
sb.WriteString("# Hard Constraints (Risk Control)\n\n")
|
||||
sb.WriteString("## CODE ENFORCED (backend validation, cannot be bypassed):\n")
|
||||
sb.WriteString(fmt.Sprintf("- Max Positions: %d instruments simultaneously\n", riskControl.MaxPositions))
|
||||
}
|
||||
|
||||
if singleSymbol {
|
||||
// One symbol — pick the higher of the two configured ratios so the
|
||||
// limit isn't accidentally clamped to the altcoin cap for a stock.
|
||||
ratio := altcoinPosValueRatio
|
||||
if btcEthPosValueRatio > ratio {
|
||||
ratio = btcEthPosValueRatio
|
||||
}
|
||||
maxVal := accountEquity * ratio
|
||||
symLabel := primarySymbol
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (%s): max %.0f USDT (= equity %.0f × %.1fx)\n", symLabel, maxVal, accountEquity, ratio))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (%s): max %.0f USDT (= equity %.0f × %.1fx)\n", symLabel, maxVal, accountEquity, ratio))
|
||||
}
|
||||
} else {
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (Altcoin/Stock): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (BTC/ETH): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (Altcoin/Stock): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (BTC/ETH): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
|
||||
}
|
||||
}
|
||||
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- Max Margin Usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min Position Size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
sb.WriteString("## AI GUIDED (recommended):\n")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Max Margin Usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min Position Size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
sb.WriteString("## AI GUIDED (recommended):\n")
|
||||
}
|
||||
|
||||
if singleSymbol {
|
||||
lev := riskControl.AltcoinMaxLeverage
|
||||
if riskControl.BTCETHMaxLeverage > lev {
|
||||
lev = riskControl.BTCETHMaxLeverage
|
||||
}
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage (%s): max %dx\n", primarySymbol, lev))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage (%s): max %dx\n", primarySymbol, lev))
|
||||
}
|
||||
} else {
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage: Altcoin/Stock max %dx | BTC/ETH max %dx\n", riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage: Altcoin/Stock max %dx | BTC/ETH max %dx\n", riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
|
||||
}
|
||||
}
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- Risk-Reward Ratio: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min Confidence: ≥%d to open position\n\n", riskControl.MinConfidence))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Risk-Reward Ratio: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min Confidence: ≥%d to open position\n\n", riskControl.MinConfidence))
|
||||
}
|
||||
|
||||
// Position sizing guidance
|
||||
exampleRatio := btcEthPosValueRatio
|
||||
if singleSymbol {
|
||||
exampleRatio = altcoinPosValueRatio
|
||||
if btcEthPosValueRatio > exampleRatio {
|
||||
exampleRatio = btcEthPosValueRatio
|
||||
}
|
||||
}
|
||||
if zh {
|
||||
sb.WriteString("## Position Sizing Guidance\n")
|
||||
sb.WriteString("Calculate `position_size_usd` from your confidence and the Position Value Limits above:\n")
|
||||
sb.WriteString("- High confidence (≥85): use 80-100%% of the position value limit\n")
|
||||
sb.WriteString("- Medium confidence (70-84): use 50-80%% of the position value limit\n")
|
||||
sb.WriteString("- Low confidence (60-69): use 30-50%% of the position value limit\n")
|
||||
sb.WriteString(fmt.Sprintf("- Example: equity %.0f × %.1fx = max %.0f USDT\n", accountEquity, exampleRatio, accountEquity*exampleRatio))
|
||||
sb.WriteString("- **DO NOT** just use available_balance as position_size_usd. Use the Position Value Limit!\n\n")
|
||||
} else {
|
||||
sb.WriteString("## Position Sizing Guidance\n")
|
||||
sb.WriteString("Calculate `position_size_usd` from your confidence and the Position Value Limits above:\n")
|
||||
sb.WriteString("- High confidence (≥85): use 80-100%% of the position value limit\n")
|
||||
sb.WriteString("- Medium confidence (70-84): use 50-80%% of the position value limit\n")
|
||||
sb.WriteString("- Low confidence (60-69): use 30-50%% of the position value limit\n")
|
||||
sb.WriteString(fmt.Sprintf("- Example: equity %.0f × %.1fx = max %.0f USDT\n", accountEquity, exampleRatio, accountEquity*exampleRatio))
|
||||
sb.WriteString("- **DO NOT** just use available_balance as position_size_usd. Use the Position Value Limit!\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
func writeOutputFormat(sb *strings.Builder, accountEquity, btcEthPosValueRatio float64, riskControl store.RiskControlConfig, singleSymbol bool, primarySymbol string, zh bool) {
|
||||
// Output format schema MUST stay English/structural; parser depends on it.
|
||||
sb.WriteString("# Output Format (Strictly Follow)\n\n")
|
||||
if zh {
|
||||
sb.WriteString("**Must use XML tags <reasoning> and <decision> to separate chain of thought and decision JSON, avoiding parsing errors**\n\n")
|
||||
} else {
|
||||
sb.WriteString("**Must use XML tags <reasoning> and <decision> to separate chain of thought and decision JSON, avoiding parsing errors**\n\n")
|
||||
}
|
||||
sb.WriteString("## Format Requirements\n\n")
|
||||
sb.WriteString("<reasoning>\n")
|
||||
if zh {
|
||||
sb.WriteString("Your chain of thought analysis...\n- Briefly analyze your thinking process\n")
|
||||
} else {
|
||||
sb.WriteString("Your chain of thought analysis...\n- Briefly analyze your thinking process\n")
|
||||
}
|
||||
sb.WriteString("</reasoning>\n\n")
|
||||
sb.WriteString("<decision>\n")
|
||||
if zh {
|
||||
sb.WriteString("Step 2: JSON decision array\n\n")
|
||||
} else {
|
||||
sb.WriteString("Step 2: JSON decision array\n\n")
|
||||
}
|
||||
sb.WriteString("```json\n[\n")
|
||||
|
||||
// Build a JSON example using the actual trading symbol when the strategy
|
||||
// is single-symbol. Falls back to the legacy BTC/ETH two-line example
|
||||
// only for multi-symbol strategies that genuinely have BTC/ETH on tap.
|
||||
if singleSymbol {
|
||||
lev := riskControl.AltcoinMaxLeverage
|
||||
if riskControl.BTCETHMaxLeverage > lev {
|
||||
lev = riskControl.BTCETHMaxLeverage
|
||||
}
|
||||
ratio := btcEthPosValueRatio // already chosen as the larger above when single-symbol
|
||||
size := accountEquity * ratio
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_long\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0},\n", primarySymbol, lev, size))
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"wait\"}\n", primarySymbol))
|
||||
} else {
|
||||
examplePositionSize := accountEquity * btcEthPosValueRatio
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n",
|
||||
riskControl.BTCETHMaxLeverage, examplePositionSize))
|
||||
sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n")
|
||||
}
|
||||
sb.WriteString("]\n```\n")
|
||||
sb.WriteString("</decision>\n\n")
|
||||
|
||||
if zh {
|
||||
sb.WriteString("## Field Description\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (opening recommended ≥ %d)\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- **IMPORTANT**: all numeric values must be calculated numbers, NOT formulas/expressions (e.g. use `27.76`, not `3000 * 0.01`)\n")
|
||||
if singleSymbol {
|
||||
sb.WriteString(fmt.Sprintf("- **This strategy trades only %s.** The JSON `symbol` MUST match `%s` exactly — do not write `%s` variants that drop the suffix or add USDT.\n", primarySymbol, primarySymbol, primarySymbol))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
} else {
|
||||
sb.WriteString("## Field Description\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (opening recommended ≥ %d)\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- **IMPORTANT**: all numeric values must be calculated numbers, NOT formulas/expressions (e.g. use `27.76`, not `3000 * 0.01`)\n")
|
||||
if singleSymbol {
|
||||
sb.WriteString(fmt.Sprintf("- **This strategy trades only %s.** The JSON `symbol` MUST match `%s` exactly — do not add USDT/USDC suffix variants.\n", primarySymbol, primarySymbol))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder, zh bool) {
|
||||
indicators := e.config.Indicators
|
||||
kline := indicators.Klines
|
||||
|
||||
label := func(en, zhStr string) string {
|
||||
if zh {
|
||||
return zhStr
|
||||
}
|
||||
return en
|
||||
}
|
||||
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- %s price series", kline.PrimaryTimeframe))
|
||||
if kline.EnableMultiTimeframe {
|
||||
sb.WriteString(fmt.Sprintf(" + %s K-line series\n", kline.LongerTimeframe))
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- %s price series", kline.PrimaryTimeframe))
|
||||
if kline.EnableMultiTimeframe {
|
||||
sb.WriteString(fmt.Sprintf(" + %s K-line series\n", kline.LongerTimeframe))
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if indicators.EnableEMA {
|
||||
sb.WriteString("- EMA indicators")
|
||||
sb.WriteString("- " + label("EMA indicators", "EMA indicators"))
|
||||
if len(indicators.EMAPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.EMAPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "periods"), indicators.EMAPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableMACD {
|
||||
sb.WriteString("- MACD indicators\n")
|
||||
sb.WriteString("- " + label("MACD indicators", "MACD indicators") + "\n")
|
||||
}
|
||||
|
||||
if indicators.EnableRSI {
|
||||
sb.WriteString("- RSI indicators")
|
||||
sb.WriteString("- " + label("RSI indicators", "RSI indicators"))
|
||||
if len(indicators.RSIPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.RSIPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "periods"), indicators.RSIPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableATR {
|
||||
sb.WriteString("- ATR indicators")
|
||||
sb.WriteString("- " + label("ATR indicators", "ATR indicators"))
|
||||
if len(indicators.ATRPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.ATRPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "periods"), indicators.ATRPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableBOLL {
|
||||
sb.WriteString("- Bollinger Bands (BOLL) - Upper/Middle/Lower bands")
|
||||
sb.WriteString("- " + label("Bollinger Bands (BOLL) - Upper/Middle/Lower bands", "Bollinger Bands (BOLL) - Upper/Middle/Lower bands"))
|
||||
if len(indicators.BOLLPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.BOLLPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "periods"), indicators.BOLLPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableVolume {
|
||||
sb.WriteString("- Volume data\n")
|
||||
sb.WriteString("- " + label("Volume data", "Volume data") + "\n")
|
||||
}
|
||||
|
||||
if indicators.EnableOI {
|
||||
sb.WriteString("- Open Interest (OI) data\n")
|
||||
sb.WriteString("- " + label("Open Interest (OI) data", "Open Interest (OI) data") + "\n")
|
||||
}
|
||||
|
||||
if indicators.EnableFundingRate {
|
||||
sb.WriteString("- Funding rate\n")
|
||||
sb.WriteString("- " + label("Funding rate", "Funding rate") + "\n")
|
||||
}
|
||||
|
||||
if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseAI500 || e.config.CoinSource.UseOITop {
|
||||
sb.WriteString("- AI500 / OI_Top filter tags (if available)\n")
|
||||
sb.WriteString("- " + label("AI500 / OI_Top filter tags (if available)", "AI500 / OI_Top filter tags (if available)") + "\n")
|
||||
}
|
||||
|
||||
if indicators.EnableQuantData {
|
||||
sb.WriteString("- Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)\n")
|
||||
sb.WriteString("- " + label("Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)", "Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)") + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,13 +807,13 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
}
|
||||
|
||||
if lang == LangChinese {
|
||||
sb.WriteString("## 历史交易统计\n")
|
||||
sb.WriteString(fmt.Sprintf("总交易: %d 笔 | 盈利因子: %.2f | 夏普比率: %.2f | 盈亏比: %.2f\n",
|
||||
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("总盈亏: %+.2f USDT | 平均盈利: +%.2f | 平均亏损: -%.2f | 最大回撤: %.1f%%\n",
|
||||
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,
|
||||
@@ -291,13 +821,13 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
|
||||
// Performance hints based on profit factor, sharpe, and drawdown
|
||||
if ctx.TradingStats.ProfitFactor >= 1.5 && ctx.TradingStats.SharpeRatio >= 1 {
|
||||
sb.WriteString("表现: 良好 - 保持当前策略\n")
|
||||
sb.WriteString("Performance: GOOD - maintain current strategy\n")
|
||||
} else if ctx.TradingStats.ProfitFactor < 1 {
|
||||
sb.WriteString("表现: 需改进 - 提高盈亏比,优化止盈止损\n")
|
||||
sb.WriteString("Performance: NEEDS IMPROVEMENT - improve win/loss ratio, optimize TP/SL\n")
|
||||
} else if ctx.TradingStats.MaxDrawdownPct > 30 {
|
||||
sb.WriteString("表现: 风险偏高 - 减少仓位,控制回撤\n")
|
||||
sb.WriteString("Performance: HIGH RISK - reduce position size, control drawdown\n")
|
||||
} else {
|
||||
sb.WriteString("表现: 正常 - 有优化空间\n")
|
||||
sb.WriteString("Performance: NORMAL - room for optimization\n")
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("## Historical Trading Statistics\n")
|
||||
@@ -368,6 +898,11 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
sb.WriteString(e.formatQuantData(quantData))
|
||||
}
|
||||
}
|
||||
if ctx.VergexDataMap != nil {
|
||||
if vergexData, hasVergex := ctx.VergexDataMap[coin.Symbol]; hasVergex {
|
||||
sb.WriteString(e.formatVergexData(vergexData))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
@@ -394,7 +929,7 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
}
|
||||
|
||||
sb.WriteString("---\n\n")
|
||||
sb.WriteString("Now please analyze and output your decision (Chain of Thought + JSON)\n")
|
||||
sb.WriteString("Now please analyze briefly and output the decision JSON.\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -433,6 +968,11 @@ func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Co
|
||||
sb.WriteString(e.formatQuantData(quantData))
|
||||
}
|
||||
}
|
||||
if ctx.VergexDataMap != nil {
|
||||
if vergexData, hasVergex := ctx.VergexDataMap[pos.Symbol]; hasVergex {
|
||||
sb.WriteString(e.formatVergexData(vergexData))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
@@ -491,11 +1031,26 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
||||
return " (Hyperliquid All)"
|
||||
case "hyper_main":
|
||||
return " (Hyperliquid Top20)"
|
||||
case "vergex_signal":
|
||||
return " (Vergex Signal)"
|
||||
}
|
||||
if strings.HasPrefix(sources[0], "hyper_rank") {
|
||||
return " (Hyperliquid Dynamic Rank)"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) formatVergexData(data *vergex.MarketAnalysis) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("\nVergex Claw402 Signals:\n")
|
||||
sb.WriteString(vergex.FormatAnalysisForAI(data))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Market Data Formatting
|
||||
// ============================================================================
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user