mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-29 00:51:22 +08:00
Compare commits
77 Commits
feat/nofxi
...
stable
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
180
README.md
180
README.md
@@ -1,8 +1,10 @@
|
||||
<p align="center"><strong>Backed by <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
||||
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Your personal AI trading assistant.</strong><br/>
|
||||
<strong>Any market. Any model. Pay with USDC, not API keys.</strong>
|
||||
<strong>AI trading terminal for global markets.</strong><br/>
|
||||
<strong>Research, strategy generation, execution, and monitoring for US stocks, commodities, forex, and crypto.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,8 +17,6 @@
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,21 +31,37 @@
|
||||
|
||||
---
|
||||
|
||||
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 AI trading terminal for active traders who want one workspace for market research, strategy development, execution, and portfolio monitoring.
|
||||
|
||||
**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.
|
||||
The product is built around global liquid markets: US equities, commodity contracts, FX pairs, and digital assets. The AI layer helps translate market intent into watchlists, signals, strategy logic, risk controls, and execution workflows.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Open **http://127.0.0.1:3000**. Done.
|
||||
Open **http://127.0.0.1:3000**.
|
||||
|
||||
---
|
||||
|
||||
## Quick Demo
|
||||
## Register exchanges
|
||||
|
||||
Use the links below to open trading accounts for crypto and supported US stock, FX, and commodity derivative markets. These routes are part of NOFX partner programs and may include fee discounts or referral benefits.
|
||||
|
||||
| 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) |
|
||||
| <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) |
|
||||
|
||||
---
|
||||
|
||||
## Quick demo
|
||||
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
@@ -59,76 +75,35 @@ Open **http://127.0.0.1:3000**. Done.
|
||||
|
||||
---
|
||||
|
||||
## How x402 Works
|
||||
## Markets
|
||||
|
||||
Traditional flow: register account → buy credits → get API key → manage quota → rotate keys.
|
||||
**US Stocks · Commodities · Forex · Crypto**
|
||||
|
||||
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 |
|
||||
NOFX organizes research, strategy construction, execution, and monitoring around multi-asset workflows instead of single-venue screens.
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
## AI model access
|
||||
|
||||
| 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 |
|
||||
NOFX routes AI inference through [Claw402](https://claw402.ai) automatically. Users do not need to configure model providers, manage API keys, or maintain separate AI accounts. The terminal accesses supported models on demand through Claw402's pay-as-you-go infrastructure, with traffic routed through the official discounted channel.
|
||||
|
||||
### Markets
|
||||
| Provider | Access |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [Access pay-as-you-go AI models with official discount](https://claw402.ai) |
|
||||
|
||||
Crypto · US Stocks · Forex · Metals
|
||||
---
|
||||
|
||||
### Exchanges (CEX)
|
||||
## Capabilities
|
||||
|
||||
| Exchange | Status | Register (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/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/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Register](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI Models (API Key Mode)
|
||||
|
||||
| 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) |
|
||||
|
||||
### AI Models (x402 Mode — No API Key)
|
||||
|
||||
15+ models via [Claw402](https://claw402.ai) — just a USDC wallet
|
||||
| Capability | Description |
|
||||
| :-------------------------- | :-------------------------------------------------------------------------- |
|
||||
| **AI trading terminal** | Unified workspace for US stocks, commodities, forex, and crypto workflows |
|
||||
| **AI model access** | Unified model access through Claw402-supported providers |
|
||||
| **Exchange connectivity** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, and Lighter |
|
||||
| **Strategy Studio** | Market universes, indicators, risk controls, and strategy logic |
|
||||
| **Model competition** | Compare model-driven traders with live performance and leaderboard tracking |
|
||||
| **Telegram agent** | Control and monitor the trading assistant through chat |
|
||||
| **Portfolio dashboard** | Positions, P/L, execution history, and model decision logs |
|
||||
|
||||
---
|
||||
|
||||
@@ -137,7 +112,7 @@ Crypto · US Stocks · Forex · Metals
|
||||
<details>
|
||||
<summary><b>Config Page</b></summary>
|
||||
|
||||
| AI Models & Exchanges | Traders List |
|
||||
| Configuration | Traders List |
|
||||
| :----------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="screenshots/config-ai-exchanges.png" width="400"/> | <img src="screenshots/config-traders-list.png" width="400"/> |
|
||||
|
||||
@@ -230,30 +205,30 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
|
||||
## 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.
|
||||
**Beginner mode**: Guided onboarding walks new users through model selection, exchange connection, strategy setup, and first deployment.
|
||||
|
||||
**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
|
||||
1. Configure AI model access
|
||||
2. Connect exchange credentials
|
||||
3. Build or import a strategy
|
||||
4. Create an AI trader profile
|
||||
5. Launch, monitor, and iterate from the dashboard
|
||||
|
||||
Everything through the web UI at **http://127.0.0.1:3000**.
|
||||
All configuration is available from the web UI at **http://127.0.0.1:3000**.
|
||||
|
||||
---
|
||||
|
||||
## Deploy to Server
|
||||
## Deploy to server
|
||||
|
||||
**HTTP (quick):**
|
||||
**HTTP deployment:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# Access via http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**HTTPS (Cloudflare):**
|
||||
**HTTPS via Cloudflare:**
|
||||
|
||||
1. Add domain to [Cloudflare](https://dash.cloudflare.com) (free plan)
|
||||
2. A record → your server IP (Proxied)
|
||||
@@ -267,24 +242,22 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Web Dashboard │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
├──────────┬──────────┬──────────┬────────────────┤
|
||||
│ Strategy │ Telegram │
|
||||
│ Engine │ Agent │
|
||||
├──────────┴──────────┴──────────┴────────────────┤
|
||||
│ MCP AI Client Layer │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ API Key │ │ x402 │ │ │ │
|
||||
│ │ DeepSeek │ │ Claw402 │ │ │ │
|
||||
│ │ GPT,Claude │ │ │ │ │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectors │
|
||||
│ Binance · Bybit · OKX · Bitget · KuCoin · Gate │
|
||||
│ Hyperliquid · Aster DEX · Lighter │
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -303,13 +276,11 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
|
||||
## Contributing
|
||||
|
||||
See [Contributing Guide](CONTRIBUTING.md) · [Code of Conduct](CODE_OF_CONDUCT.md) · [Security Policy](SECURITY.md)
|
||||
See [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 reward weight.
|
||||
|
||||
| Contribution | Weight |
|
||||
| :---------------- | :----: |
|
||||
@@ -326,13 +297,12 @@ All contributions are tracked. When NOFX generates revenue, contributors receive
|
||||
|
||||
| | |
|
||||
| :-------- | :---------------------------------------------------- |
|
||||
| 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) |
|
||||
| Website | [vergex.trade](https://vergex.trade) |
|
||||
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
|
||||
|
||||
> **Risk Warning**: AI auto-trading carries significant risks. Recommended for learning/research or small amounts only.
|
||||
> **Risk warning**: Automated trading involves substantial risk. Use appropriate position sizing, understand each exchange venue, and do not trade funds you cannot afford to lose.
|
||||
|
||||
---
|
||||
|
||||
|
||||
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,29 +319,23 @@ 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
|
||||
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
|
||||
}
|
||||
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:
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
type UpdateModelConfigRequest struct {
|
||||
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"`
|
||||
} `json:"models"`
|
||||
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,18 +432,20 @@ 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,
|
||||
"total_pnl_pct": pnlPct,
|
||||
"balance": snap.Balance,
|
||||
"timestamp": snap.Timestamp,
|
||||
"total_equity": snap.TotalEquity,
|
||||
"available_balance": equitySnapshotAvailableBalance(snap),
|
||||
"total_pnl": totalPnL,
|
||||
"total_pnl_pct": pnlPct,
|
||||
"balance": snap.Balance,
|
||||
})
|
||||
if snap.Timestamp.After(lastSnapshotTime) {
|
||||
lastSnapshotTime = snap.Timestamp
|
||||
@@ -397,29 +458,21 @@ 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
|
||||
}
|
||||
|
||||
history = append(history, map[string]interface{}{
|
||||
"timestamp": now,
|
||||
"total_equity": totalEquity,
|
||||
"total_pnl": totalPnL,
|
||||
"total_pnl_pct": pnlPct,
|
||||
"balance": walletBalance,
|
||||
"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"
|
||||
)
|
||||
@@ -24,56 +26,96 @@ type ExchangeConfig struct {
|
||||
|
||||
// SafeExchangeConfig Safe exchange configuration structure (does not contain sensitive information)
|
||||
type SafeExchangeConfig struct {
|
||||
ID string `json:"id"` // UUID
|
||||
ExchangeType string `json:"exchange_type"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
AccountName string `json:"account_name"` // User-defined account name
|
||||
Name string `json:"name"` // Display name
|
||||
Type string `json:"type"` // "cex" or "dex"
|
||||
Enabled bool `json:"enabled"`
|
||||
Testnet bool `json:"testnet,omitempty"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
|
||||
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)
|
||||
ID string `json:"id"` // UUID
|
||||
ExchangeType string `json:"exchange_type"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
AccountName string `json:"account_name"` // User-defined account name
|
||||
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"`
|
||||
}
|
||||
|
||||
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
|
||||
HyperliquidBuilderApproved *bool `json:"hyperliquid_builder_approved"`
|
||||
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"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||
}
|
||||
|
||||
type UpdateExchangeConfigRequest struct {
|
||||
Exchanges map[string]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
|
||||
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"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||
} `json:"exchanges"`
|
||||
Exchanges map[string]ExchangeConfigUpdate `json:"exchanges"`
|
||||
}
|
||||
|
||||
// CreateExchangeRequest request structure for creating a new exchange account
|
||||
type CreateExchangeRequest struct {
|
||||
ExchangeType string `json:"exchange_type" binding:"required"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
AccountName string `json:"account_name"` // User-defined account name
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
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
|
||||
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"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||
ExchangeType string `json:"exchange_type" binding:"required"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
AccountName string `json:"account_name"` // User-defined account name
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
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
|
||||
HyperliquidBuilderApproved bool `json:"hyperliquid_builder_approved"`
|
||||
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"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||
}
|
||||
|
||||
// handleGetExchangeConfigs Get exchange configurations
|
||||
@@ -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) — 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"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"` // crypto, stock, forex, commodity, index
|
||||
MaxLeverage int `json:"maxLeverage,omitempty"`
|
||||
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
|
||||
}
|
||||
symbols = append(symbols, SymbolInfo{
|
||||
Symbol: symbol,
|
||||
Name: symbol,
|
||||
Category: "crypto",
|
||||
})
|
||||
}
|
||||
// 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: 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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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"`
|
||||
@@ -65,6 +70,24 @@ func traderCreationRequestError(reason string) string {
|
||||
return formatTraderCreationError(reason, "请检查你刚刚填写的内容后,再重新提交")
|
||||
}
|
||||
|
||||
func validateTraderLeverageRange(btcEthLeverage, altcoinLeverage int) (string, string) {
|
||||
if btcEthLeverage < 0 || btcEthLeverage > maxManualBTCETHLeverage {
|
||||
return traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_btc_eth_leverage"
|
||||
}
|
||||
if altcoinLeverage < 0 || altcoinLeverage > maxManualAltLeverage {
|
||||
return traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "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 "所选交易所账户"
|
||||
@@ -158,12 +181,12 @@ func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string
|
||||
missing := missingExchangeFields(exchange)
|
||||
if len(missing) > 0 {
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」的配置还不完整,缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")),
|
||||
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
|
||||
), "trader.create.exchange_missing_fields", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"missing_fields", strings.Join(missing, ", "),
|
||||
)
|
||||
fmt.Sprintf("交易所账户「%s」的配置还不完整,缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")),
|
||||
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
|
||||
), "trader.create.exchange_missing_fields", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"missing_fields", strings.Join(missing, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
switch exchange.ExchangeType {
|
||||
@@ -171,12 +194,12 @@ func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string
|
||||
return "", "", nil
|
||||
default:
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
|
||||
"请改用当前版本支持的交易所账户后,再重新创建机器人",
|
||||
), "trader.create.exchange_unsupported", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"exchange_type", exchange.ExchangeType,
|
||||
)
|
||||
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
|
||||
"请改用当前版本支持的交易所账户后,再重新创建机器人",
|
||||
), "trader.create.exchange_unsupported", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"exchange_type", exchange.ExchangeType,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,24 +329,22 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
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("交易对 %s 的格式不正确,目前只支持 USDT 合约或 Hyperliquid XYZ USDC 标的(SYMBOL-USDC)", symbol),
|
||||
), "trader.create.invalid_symbol", mapStringPairs("symbol", symbol))
|
||||
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
|
||||
@@ -520,14 +543,14 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
|
||||
if startupWarning == "" {
|
||||
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
|
||||
logger.Infof("⚠️ Trader %s failed to load after creation: %v", traderID, loadErr)
|
||||
logger.Infof("⚠️ Trader %s failed to load after creation: %v", traderID, loadErr)
|
||||
startupWarning = describeTraderCreationWarning(req.Name, loadErr)
|
||||
}
|
||||
}
|
||||
|
||||
if startupWarning == "" {
|
||||
if _, getErr := s.traderManager.GetTrader(traderID); getErr != nil {
|
||||
logger.Infof("⚠️ Trader %s not found in memory after creation: %v", traderID, getErr)
|
||||
logger.Infof("⚠️ Trader %s not found in memory after creation: %v", traderID, getErr)
|
||||
startupWarning = describeTraderCreationWarning(req.Name, getErr)
|
||||
}
|
||||
}
|
||||
@@ -535,11 +558,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
logger.Infof("✓ Trader created successfully: %s (model: %s, exchange: %s)", req.Name, req.AIModelID, req.ExchangeID)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"trader_id": traderID,
|
||||
"trader_name": req.Name,
|
||||
"ai_model": req.AIModelID,
|
||||
"is_running": false,
|
||||
"startup_warning": startupWarning,
|
||||
"trader_id": traderID,
|
||||
"trader_name": req.Name,
|
||||
"ai_model": req.AIModelID,
|
||||
"is_running": false,
|
||||
"startup_warning": startupWarning,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -751,6 +779,14 @@ 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("机器人「%s」的 Hyperliquid 交易授权尚未完成", traderName),
|
||||
"请重新连接 Hyperliquid 钱包并完成交易授权后,再启动机器人",
|
||||
), "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 {
|
||||
|
||||
@@ -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)
|
||||
s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result)
|
||||
// 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 自动策略", "唯一内置策略:每轮读取 Claw402.ai 榜单,逐个拉取 Signal Lab 与成本/清算热力图,再结合原始 K 线决策。"},
|
||||
},
|
||||
"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,42 @@ 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 = 10
|
||||
c.RiskControl.AltcoinMaxLeverage = 10
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = 10.0
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 10.0
|
||||
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 +301,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 +317,49 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
strategies = append(strategies, strategy)
|
||||
}
|
||||
|
||||
legacyDefaultNames := []string{
|
||||
"均衡策略", "稳健策略", "积极策略",
|
||||
"美股趋势策略", "美股稳健策略", "美股突破策略",
|
||||
"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 == "均衡策略" || strategy.Name == "稳健策略" || strategy.Name == "积极策略" {
|
||||
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 自动策略"]
|
||||
if defaultStrategy == nil || !defaultStrategy.IsActive {
|
||||
t.Fatalf("NOFX Claw402 自动策略 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 at most two positions, got coin=%+v risk=%+v", trendCfg.CoinSource, trendCfg.RiskControl)
|
||||
}
|
||||
if trendCfg.RiskControl.BTCETHMaxLeverage != 10 || trendCfg.RiskControl.AltcoinMaxLeverage != 10 {
|
||||
t.Fatalf("default strategy should use 10x leverage for all Claw402 opens, got risk=%+v", trendCfg.RiskControl)
|
||||
}
|
||||
if trendCfg.RiskControl.BTCETHMaxPositionValueRatio != 10 ||
|
||||
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 10 ||
|
||||
trendCfg.RiskControl.MaxMarginUsage != 1.0 {
|
||||
t.Fatalf("default strategy should use full-size 10x notional for Claw402 opens, 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: "均衡策略",
|
||||
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["均衡策略"] != 0 {
|
||||
t.Fatalf("legacy preset should be removed, got names=%+v", byName)
|
||||
}
|
||||
if byName["NOFX Claw402 自动策略"] != 1 {
|
||||
t.Fatalf("expected exactly one NOFX Claw402 自动策略, 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)
|
||||
}
|
||||
}
|
||||
115
api/handler_vergex.go
Normal file
115
api/handler_vergex.go
Normal file
@@ -0,0 +1,115 @@
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
161
api/server.go
161
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,10 @@ 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)
|
||||
|
||||
// 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 +224,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",
|
||||
@@ -259,10 +335,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
|
||||
@@ -511,34 +587,45 @@ 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 {
|
||||
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]
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
|
||||
return s.traderManager, traderID, nil
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
@@ -162,10 +188,12 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
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
|
||||
Name string `json:"name" binding:"required"`
|
||||
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,7 +238,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
Description: req.Description,
|
||||
IsActive: false,
|
||||
IsDefault: false,
|
||||
Config: string(configJSON),
|
||||
IsPublic: isPublic,
|
||||
// Existing default is true; keep that behavior when no explicit publish config is sent.
|
||||
ConfigVisible: configVisible || !hadPublishConfig,
|
||||
Config: string(configJSON),
|
||||
}
|
||||
|
||||
if err := s.store.Strategy().Create(strategy); err != nil {
|
||||
@@ -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,12 +282,16 @@ func isEncryptedStorageValue(value string) bool {
|
||||
}
|
||||
|
||||
func (cs *CryptoService) DecryptPayload(payload *EncryptedPayload) ([]byte, error) {
|
||||
// 1. Validate timestamp (prevent replay attacks)
|
||||
if payload.TS != 0 {
|
||||
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")
|
||||
}
|
||||
// 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
|
||||
@@ -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
|
||||
|
||||
@@ -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,8 +1,10 @@
|
||||
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a> によるバックアップ</strong></p>
|
||||
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>あなた専属の AI トレーディングアシスタント。</strong><br/>
|
||||
<strong>あらゆる市場。あらゆるモデル。API キー不要、USDC で支払い。</strong>
|
||||
<strong>グローバル市場向け AI トレーディングターミナル。</strong><br/>
|
||||
<strong>米国株、コモディティ、FX、暗号資産のリサーチ、戦略生成、執行、モニタリング。</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,8 +17,6 @@
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,17 +31,33 @@
|
||||
|
||||
---
|
||||
|
||||
NOFX はオープンソースの**自律型** AI トレーディングアシスタントです。従来の AI ツールのように手動でモデルを設定し、API キーを管理し、データソースを接続する必要はありません — NOFX の AI は**市場を自ら認識し、モデルを自ら選択し、データを自ら取得します**。人間の介入はゼロ。あなたは戦略を設定するだけ、残りは AI が処理します。
|
||||
NOFX は、マーケットリサーチ、戦略開発、取引執行、ポートフォリオ監視をひとつのワークスペースで行うためのオープンソース AI トレーディングターミナルです。
|
||||
|
||||
**完全自律**: AI がどのモデルを使うか、どの市場データを取得するか、いつ取引するかを自ら判断します。手動のモデル設定不要。複数サービスの API キー管理不要。USDC ウォレットに入金して実行するだけ。
|
||||
|
||||
他との違い:**[x402](https://x402.org) マイクロペイメント内蔵**。API キー不要。USDC ウォレットに入金してリクエストごとに支払い。ウォレットがあなたの身分証明。
|
||||
対象は米国株、コモディティ契約、FX ペア、デジタル資産などの高流動性グローバル市場です。AI レイヤーは取引意図をウォッチリスト、シグナル、戦略ロジック、リスク制御、執行ワークフローへ変換します。
|
||||
|
||||
```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** を開きます。
|
||||
|
||||
---
|
||||
|
||||
## 取引所登録
|
||||
|
||||
以下のリンクから、暗号資産および対応する米国株、FX、コモディティデリバティブ市場向けの取引口座を開設できます。これらは NOFX のパートナープログラム経由で、手数料割引または紹介特典が適用される場合があります。
|
||||
|
||||
| 取引所 | 状態 | 手数料割引付き登録 |
|
||||
| :--- | :---: | :--- |
|
||||
| <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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -49,88 +65,93 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX クイックデモ動画" width="900"/>
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
カバー画像をクリックするとデモ動画を視聴できます。
|
||||
カバー画像をクリックしてデモ動画をご覧ください。
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## x402 の仕組み
|
||||
## 市場
|
||||
|
||||
従来のフロー:アカウント登録 → クレジット購入 → API キー取得 → クォータ管理 → キーのローテーション。
|
||||
**米国株 · コモディティ · FX · 暗号資産**
|
||||
|
||||
x402 フロー:
|
||||
NOFX は単一取引所の画面ではなく、マルチアセットのリサーチ、戦略構築、執行、監視ワークフローを中心に設計されています。
|
||||
|
||||
```
|
||||
リクエスト → 402(価格提示)→ ウォレットが USDC を署名 → リトライ → 完了
|
||||
```
|
||||
---
|
||||
|
||||
アカウント不要。API キー不要。前払いクレジット不要。ウォレット1つで全モデル。
|
||||
## AI モデルアクセス
|
||||
|
||||
### 内蔵 x402 プロバイダー
|
||||
NOFX は AI 推論を [Claw402](https://claw402.ai) 経由で自動ルーティングします。ユーザーはモデルプロバイダーの設定、API キー管理、個別 AI アカウントの維持を行う必要がありません。ターミナルは Claw402 の従量課金インフラを使って対応モデルへオンデマンドにアクセスし、公式割引チャネルを通じてルーティングします。
|
||||
|
||||
| プロバイダー | チェーン | モデル |
|
||||
|:---------|:------|:-------|
|
||||
| <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+ モデル |
|
||||
| プロバイダー | アクセス |
|
||||
| :--- | :--- |
|
||||
| **Claw402** | [公式割引で従量課金 AI モデルにアクセス](https://claw402.ai) |
|
||||
|
||||
---
|
||||
|
||||
## 機能
|
||||
|
||||
| 機能 | 説明 |
|
||||
|:--------|:------------|
|
||||
| **マルチ 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 判断ログ |
|
||||
| :--- | :--- |
|
||||
| **AI トレーディングターミナル** | 米国株、コモディティ、FX、暗号資産向けの統合ワークスペース |
|
||||
| **AI モデルアクセス** | Claw402 経由で対応プロバイダーへ自動接続 |
|
||||
| **取引所接続** | Binance、Bybit、OKX、Hyperliquid、Bitget、KuCoin、Gate、Aster、Lighter |
|
||||
| **Strategy Studio** | 市場ユニバース、インジケーター、リスク制御、戦略ロジック |
|
||||
| **モデル競争** | AI トレーダーのライブ成績とランキングを比較 |
|
||||
| **Telegram Agent** | チャットからトレーディングアシスタントを操作・監視 |
|
||||
| **ポートフォリオダッシュボード** | ポジション、損益、執行履歴、モデル判断ログ |
|
||||
|
||||
### 市場
|
||||
---
|
||||
|
||||
暗号通貨 · 米国株 · FX · 貴金属
|
||||
## スクリーンショット
|
||||
|
||||
### 取引所 (CEX)
|
||||
<details>
|
||||
<summary><b>設定ページ</b></summary>
|
||||
|
||||
| 取引所 | ステータス | 登録 (手数料割引) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <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) |
|
||||
| 設定 | トレーダー一覧 |
|
||||
| :----------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
|
||||
|
||||
### 取引所 (Perp-DEX)
|
||||
</details>
|
||||
|
||||
| 取引所 | ステータス | 登録 (手数料割引) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <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) |
|
||||
<details>
|
||||
<summary><b>ダッシュボード</b></summary>
|
||||
|
||||
### AI モデル (API キーモード)
|
||||
| 概要 | マーケットチャート |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| 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) |
|
||||
| 取引統計 | ポジション履歴 |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
### AI モデル (x402 モード — API キー不要)
|
||||
| ポジション | トレーダー詳細 |
|
||||
| :----------------------------------------------------------: | :---------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
|
||||
|
||||
15+ モデルを [Claw402](https://claw402.ai) 経由で利用 — USDC ウォレットのみ
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Strategy Studio</b></summary>
|
||||
|
||||
| 戦略エディタ | インジケーター設定 |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>コンペティション</b></summary>
|
||||
|
||||
| コンペティションモード |
|
||||
| :-------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -142,7 +163,7 @@ x402 フロー:
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (クラウド)
|
||||
### Railway(クラウド)
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
@@ -153,33 +174,154 @@ curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### ソースから
|
||||
### 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
|
||||
```
|
||||
|
||||
### ソースからビルド
|
||||
|
||||
```bash
|
||||
# 前提条件: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# 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 # バックエンド
|
||||
cd web && npm install && npm run dev # フロントエンド(新しいターミナル)
|
||||
go build -o nofx && ./nofx
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
### アップデート
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## セットアップ
|
||||
|
||||
**初心者モード**:ガイド付き onboarding により、モデルアクセス、取引所接続、戦略設定、初回デプロイまで進められます。
|
||||
|
||||
**上級モード**:
|
||||
|
||||
1. AI モデルアクセスを設定
|
||||
2. 取引所の認証情報を接続
|
||||
3. 戦略を構築またはインポート
|
||||
4. AI トレーダープロファイルを作成
|
||||
5. ダッシュボードから起動、監視、改善
|
||||
|
||||
すべての設定は Web UI **http://127.0.0.1:3000** から行えます。
|
||||
|
||||
---
|
||||
|
||||
## サーバーへのデプロイ
|
||||
|
||||
**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` を設定
|
||||
|
||||
---
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ドキュメント
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| [アーキテクチャ](../../architecture/README.md) | システム設計とモジュール索引 |
|
||||
| [戦略モジュール](../../architecture/STRATEGY_MODULE.md) | 銘柄選択、AI プロンプト、執行 |
|
||||
| [FAQ](../../faq/README.md) | よくある質問 |
|
||||
| [はじめに](../../getting-started/README.md) | デプロイガイド |
|
||||
|
||||
---
|
||||
|
||||
## 貢献
|
||||
|
||||
[貢献ガイド](../../../CONTRIBUTING.md)、[行動規範](../../../CODE_OF_CONDUCT.md)、[セキュリティポリシー](../../../SECURITY.md) を参照してください。
|
||||
|
||||
### 貢献者プログラム
|
||||
|
||||
NOFX は有意義な貢献を記録し、エコシステムの成長に応じて貢献者へ還元する予定です。優先 Issue は高い報酬ウェイトを持ちます。
|
||||
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## リンク
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| ウェブサイト | [nofxai.com](https://nofxai.com) |
|
||||
| ダッシュボード | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API ドキュメント | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| :--- | :--- |
|
||||
| Website | [vergex.trade](https://vergex.trade) |
|
||||
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
|
||||
|
||||
> **リスク警告**: AI 自動取引には重大なリスクがあります。学習/研究目的または少額でのテストのみを推奨します。
|
||||
> **リスク警告**:自動売買には大きなリスクがあります。適切なポジションサイズを守り、各取引所の仕組みを理解し、失ってもよい資金だけを使用してください。
|
||||
|
||||
---
|
||||
|
||||
## スポンサー
|
||||
|
||||
<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)
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a>가 지원합니다</strong></p>
|
||||
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>당신만의 AI 트레이딩 어시스턴트.</strong><br/>
|
||||
<strong>모든 시장. 모든 모델. API 키 없이 USDC로 결제.</strong>
|
||||
<strong>글로벌 시장을 위한 AI 트레이딩 터미널.</strong><br/>
|
||||
<strong>미국 주식, 원자재, 외환, 암호화폐 리서치, 전략 생성, 실행, 모니터링.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,8 +17,6 @@
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,17 +31,33 @@
|
||||
|
||||
---
|
||||
|
||||
NOFX는 오픈소스 **자율형** AI 트레이딩 어시스턴트입니다. 수동으로 모델을 설정하고, API 키를 관리하고, 데이터 소스를 연결해야 하는 기존 AI 도구와 달리 — NOFX의 AI는 **시장을 스스로 인식하고, 모델을 스스로 선택하고, 데이터를 스스로 가져옵니다**. 인간 개입 제로. 전략만 설정하면 나머지는 AI가 처리합니다.
|
||||
NOFX는 시장 리서치, 전략 개발, 거래 실행, 포트폴리오 모니터링을 하나의 워크스페이스에서 처리하는 오픈소스 AI 트레이딩 터미널입니다.
|
||||
|
||||
**완전 자율**: AI가 어떤 모델을 사용할지, 어떤 시장 데이터를 가져올지, 언제 거래할지를 스스로 결정합니다. 수동 모델 설정 불필요. 여러 서비스의 API 키 관리 불필요. USDC 지갑에 충전하고 실행하기만 하면 됩니다.
|
||||
|
||||
차별점: **[x402](https://x402.org) 마이크로 결제 내장**. API 키 불필요. USDC 지갑에 충전하고 요청마다 결제. 지갑이 곧 신원.
|
||||
제품은 미국 주식, 원자재 계약, FX 페어, 디지털 자산 등 글로벌 유동성 시장을 중심으로 설계되었습니다. AI 레이어는 거래 의도를 워치리스트, 신호, 전략 로직, 리스크 제어, 실행 워크플로로 변환합니다.
|
||||
|
||||
```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** 을 엽니다.
|
||||
|
||||
---
|
||||
|
||||
## 거래소 등록
|
||||
|
||||
아래 링크를 통해 암호화폐와 지원되는 미국 주식, FX, 원자재 파생상품 시장용 거래 계정을 개설할 수 있습니다. 이 링크는 NOFX 파트너 프로그램을 통해 제공되며 수수료 할인 또는 추천 혜택이 포함될 수 있습니다.
|
||||
|
||||
| 거래소 | 상태 | 수수료 할인 등록 |
|
||||
| :--- | :---: | :--- |
|
||||
| <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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -49,88 +65,93 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX 빠른 데모 영상" width="900"/>
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
커버 이미지를 클릭하면 데모 영상을 볼 수 있습니다.
|
||||
커버 이미지를 클릭해 데모 영상을 보세요.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## x402 작동 방식
|
||||
## 시장
|
||||
|
||||
기존 플로우: 계정 등록 → 크레딧 구매 → API 키 받기 → 쿼터 관리 → 키 교체.
|
||||
**미국 주식 · 원자재 · 외환 · 암호화폐**
|
||||
|
||||
x402 플로우:
|
||||
NOFX는 단일 거래소 화면이 아니라 멀티에셋 리서치, 전략 구축, 실행, 모니터링 워크플로를 중심으로 구성됩니다.
|
||||
|
||||
```
|
||||
요청 → 402 (가격 제시) → 지갑이 USDC 서명 → 재시도 → 완료
|
||||
```
|
||||
---
|
||||
|
||||
계정 불필요. API 키 불필요. 선불 크레딧 불필요. 지갑 하나로 모든 모델.
|
||||
## AI 모델 액세스
|
||||
|
||||
### 내장 x402 프로바이더
|
||||
NOFX는 AI 추론을 [Claw402](https://claw402.ai)를 통해 자동 라우팅합니다. 사용자는 모델 제공업체를 설정하거나 API 키를 관리하거나 별도 AI 계정을 유지할 필요가 없습니다. 터미널은 Claw402의 사용량 기반 인프라를 통해 지원 모델에 온디맨드로 접근하며 공식 할인 채널로 트래픽을 라우팅합니다.
|
||||
|
||||
| 프로바이더 | 체인 | 모델 |
|
||||
|:---------|:------|:-------|
|
||||
| <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+ 모델 |
|
||||
| 제공업체 | 액세스 |
|
||||
| :--- | :--- |
|
||||
| **Claw402** | [공식 할인으로 사용량 기반 AI 모델 이용](https://claw402.ai) |
|
||||
|
||||
---
|
||||
|
||||
## 기능
|
||||
|
||||
| 기능 | 설명 |
|
||||
|:--------|:------------|
|
||||
| **멀티 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 결정 로그 |
|
||||
| :--- | :--- |
|
||||
| **AI 트레이딩 터미널** | 미국 주식, 원자재, 외환, 암호화폐 워크플로를 위한 통합 워크스페이스 |
|
||||
| **AI 모델 액세스** | Claw402를 통해 지원 모델 제공업체에 자동 연결 |
|
||||
| **거래소 연결** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, Lighter |
|
||||
| **Strategy Studio** | 시장 유니버스, 지표, 리스크 제어, 전략 로직 |
|
||||
| **모델 경쟁** | AI 트레이더의 실시간 성과와 리더보드 비교 |
|
||||
| **Telegram Agent** | 채팅으로 트레이딩 어시스턴트 제어 및 모니터링 |
|
||||
| **포트폴리오 대시보드** | 포지션, 손익, 실행 기록, 모델 의사결정 로그 |
|
||||
|
||||
### 시장
|
||||
---
|
||||
|
||||
암호화폐 · 미국 주식 · 외환 · 귀금속
|
||||
## 스크린샷
|
||||
|
||||
### 거래소 (CEX)
|
||||
<details>
|
||||
<summary><b>설정 페이지</b></summary>
|
||||
|
||||
| 거래소 | 상태 | 등록 (수수료 할인) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <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) |
|
||||
| 설정 | 트레이더 목록 |
|
||||
| :----------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
|
||||
|
||||
### 거래소 (Perp-DEX)
|
||||
</details>
|
||||
|
||||
| 거래소 | 상태 | 등록 (수수료 할인) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <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) |
|
||||
<details>
|
||||
<summary><b>대시보드</b></summary>
|
||||
|
||||
### AI 모델 (API 키 모드)
|
||||
| 개요 | 마켓 차트 |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| 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) |
|
||||
| 거래 통계 | 포지션 기록 |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
### AI 모델 (x402 모드 — API 키 불필요)
|
||||
| 포지션 | 트레이더 상세 |
|
||||
| :----------------------------------------------------------: | :---------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
|
||||
|
||||
15+ 모델을 [Claw402](https://claw402.ai)로 이용 — USDC 지갑만 있으면 됩니다
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Strategy Studio</b></summary>
|
||||
|
||||
| 전략 에디터 | 지표 설정 |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>경쟁</b></summary>
|
||||
|
||||
| 경쟁 모드 |
|
||||
| :-------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -142,7 +163,7 @@ x402 플로우:
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (클라우드)
|
||||
### Railway(클라우드)
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
@@ -153,33 +174,154 @@ curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 소스에서
|
||||
### 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
|
||||
```
|
||||
|
||||
### 소스에서 빌드
|
||||
|
||||
```bash
|
||||
# 필수 조건: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# 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 # 백엔드
|
||||
cd web && npm install && npm run dev # 프론트엔드 (새 터미널)
|
||||
go build -o nofx && ./nofx
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
### 업데이트
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 설정
|
||||
|
||||
**초보자 모드**: 가이드 온보딩이 모델 액세스, 거래소 연결, 전략 설정, 첫 배포까지 안내합니다.
|
||||
|
||||
**고급 모드**:
|
||||
|
||||
1. AI 모델 액세스 설정
|
||||
2. 거래소 인증 정보 연결
|
||||
3. 전략 생성 또는 가져오기
|
||||
4. AI 트레이더 프로필 생성
|
||||
5. 대시보드에서 실행, 모니터링, 개선
|
||||
|
||||
모든 설정은 Web UI **http://127.0.0.1:3000** 에서 가능합니다.
|
||||
|
||||
---
|
||||
|
||||
## 서버에 배포
|
||||
|
||||
**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` 설정
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 문서
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| [아키텍처](../../architecture/README.md) | 시스템 설계와 모듈 색인 |
|
||||
| [전략 모듈](../../architecture/STRATEGY_MODULE.md) | 종목 선택, AI 프롬프트, 실행 |
|
||||
| [FAQ](../../faq/README.md) | 자주 묻는 질문 |
|
||||
| [시작하기](../../getting-started/README.md) | 배포 가이드 |
|
||||
|
||||
---
|
||||
|
||||
## 기여
|
||||
|
||||
[기여 가이드](../../../CONTRIBUTING.md), [행동 강령](../../../CODE_OF_CONDUCT.md), [보안 정책](../../../SECURITY.md)을 확인하세요.
|
||||
|
||||
### 기여자 프로그램
|
||||
|
||||
NOFX는 의미 있는 기여를 기록하며 생태계 성장에 따라 기여자에게 보상할 계획입니다. 우선순위 이슈는 더 높은 보상 가중치를 가집니다.
|
||||
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## 링크
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| 웹사이트 | [nofxai.com](https://nofxai.com) |
|
||||
| 대시보드 | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API 문서 | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| :--- | :--- |
|
||||
| Website | [vergex.trade](https://vergex.trade) |
|
||||
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
|
||||
|
||||
> **위험 경고**: AI 자동 거래에는 상당한 위험이 있습니다. 학습/연구 또는 소액 테스트만 권장합니다.
|
||||
> **위험 고지**: 자동매매에는 상당한 위험이 따릅니다. 적절한 포지션 규모를 사용하고 각 거래소 구조를 이해하며 감당 가능한 자금만 거래하세요.
|
||||
|
||||
---
|
||||
|
||||
## 스폰서
|
||||
|
||||
<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)
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<p align="center"><strong>При поддержке <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
||||
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Ваш персональный AI торговый ассистент.</strong><br/>
|
||||
<strong>Любой рынок. Любая модель. Оплата USDC, без API ключей.</strong>
|
||||
<strong>AI-терминал для глобальных рынков.</strong><br/>
|
||||
<strong>Исследования, генерация стратегий, исполнение и мониторинг для акций США, сырьевых товаров, FX и криптоактивов.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,8 +17,6 @@
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,106 +31,127 @@
|
||||
|
||||
---
|
||||
|
||||
NOFX — это **автономный** AI торговый ассистент с открытым исходным кодом. В отличие от традиционных AI инструментов, где нужно вручную настраивать модели, управлять API ключами и подключать источники данных — AI в NOFX **сам анализирует рынки, выбирает модели и получает данные**. Нулевое вмешательство человека. Вы задаёте стратегию, AI делает всё остальное.
|
||||
NOFX — open-source AI-терминал для активных трейдеров, которым нужен единый рабочий контур для анализа рынков, разработки стратегий, исполнения сделок и мониторинга портфеля.
|
||||
|
||||
**Полная автономность**: AI сам решает, какую модель использовать, какие рыночные данные получить, когда торговать. Без ручной настройки моделей. Без жонглирования API ключами разных сервисов. Просто пополните USDC кошелёк и запустите.
|
||||
|
||||
Ключевое отличие: **встроенные [x402](https://x402.org) микроплатежи**. Без API ключей. Пополните USDC кошелёк и платите за каждый запрос. Кошелёк — это ваша идентификация.
|
||||
Продукт ориентирован на ликвидные глобальные рынки: акции США, товарные контракты, валютные пары и цифровые активы. AI-слой превращает торговое намерение в списки наблюдения, сигналы, стратегическую логику, риск-контроль и рабочие процессы исполнения.
|
||||
|
||||
```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** 을 엽니다.
|
||||
|
||||
---
|
||||
|
||||
## Быстрое демо
|
||||
## Регистрация на биржах
|
||||
|
||||
Используйте ссылки ниже для открытия торговых счетов на крипторынках и поддерживаемых рынках деривативов на акции США, FX и сырьевые товары. Эти маршруты относятся к партнерским программам NOFX и могут включать скидки на комиссии или реферальные преимущества.
|
||||
|
||||
| Биржа | Статус | Регистрация со скидкой |
|
||||
| :--- | :---: | :--- |
|
||||
| <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) |
|
||||
|
||||
---
|
||||
|
||||
## Короткая демонстрация
|
||||
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="Видео быстрого демо NOFX" width="900"/>
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Нажмите на изображение обложки, чтобы посмотреть демо-видео.
|
||||
Нажмите на обложку, чтобы посмотреть демо.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Как работает x402
|
||||
## Рынки
|
||||
|
||||
Традиционный процесс: регистрация → покупка кредитов → получение API ключа → управление квотой → ротация ключей.
|
||||
**Акции США · Сырьевые товары · FX · Криптоактивы**
|
||||
|
||||
x402 процесс:
|
||||
NOFX организует исследования, построение стратегий, исполнение и мониторинг вокруг мультиактивных рабочих процессов, а не вокруг одного торгового экрана.
|
||||
|
||||
```
|
||||
Запрос → 402 (вот цена) → кошелёк подписывает USDC → повтор → готово
|
||||
```
|
||||
---
|
||||
|
||||
Без аккаунтов. Без API ключей. Без предоплаты. Один кошелёк, все модели.
|
||||
## Доступ к AI-моделям
|
||||
|
||||
### Встроенные x402 провайдеры
|
||||
NOFX автоматически маршрутизирует AI-инференс через [Claw402](https://claw402.ai). Пользователям не нужно настраивать провайдеров моделей, управлять API-ключами или поддерживать отдельные AI-аккаунты. Терминал обращается к поддерживаемым моделям по требованию через pay-as-you-go инфраструктуру Claw402 и официальный канал со скидкой.
|
||||
|
||||
| Провайдер | Сеть | Модели |
|
||||
|:---------|:------|:-------|
|
||||
| <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+ моделей |
|
||||
| Провайдер | Доступ |
|
||||
| :--- | :--- |
|
||||
| **Claw402** | [Доступ к AI-моделям по мере использования с официальной скидкой](https://claw402.ai) |
|
||||
|
||||
---
|
||||
|
||||
## Возможности
|
||||
|
||||
| Функция | Описание |
|
||||
|:--------|:------------|
|
||||
| **Мульти-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 |
|
||||
| Возможность | Описание |
|
||||
| :--- | :--- |
|
||||
| **AI trading terminal** | Единое рабочее пространство для акций США, сырья, FX и криптоактивов |
|
||||
| **Доступ к AI-моделям** | Автоматический доступ к поддерживаемым провайдерам через Claw402 |
|
||||
| **Подключение бирж** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, Lighter |
|
||||
| **Strategy Studio** | Рыночные универсумы, индикаторы, риск-контроль и логика стратегий |
|
||||
| **Соревнование моделей** | Сравнение AI-трейдеров по live-результатам и таблице лидеров |
|
||||
| **Telegram Agent** | Управление и мониторинг ассистента через чат |
|
||||
| **Портфельный дашборд** | Позиции, P/L, история исполнения и логи решений модели |
|
||||
|
||||
### Рынки
|
||||
---
|
||||
|
||||
Криптовалюта · Акции США · Форекс · Металлы
|
||||
## Скриншоты
|
||||
|
||||
### Биржи (CEX)
|
||||
<details>
|
||||
<summary><b>Страница настроек</b></summary>
|
||||
|
||||
| Биржа | Статус | Регистрация (скидка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <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) |
|
||||
| Конфигурация | Список трейдеров |
|
||||
| :----------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
|
||||
|
||||
### Биржи (Perp-DEX)
|
||||
</details>
|
||||
|
||||
| Биржа | Статус | Регистрация (скидка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <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) |
|
||||
<details>
|
||||
<summary><b>Дашборд</b></summary>
|
||||
|
||||
### AI Модели (Режим API ключей)
|
||||
| Обзор | График рынка |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| 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) |
|
||||
| Статистика торгов | История позиций |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
### AI Модели (Режим x402 — без API ключей)
|
||||
| Позиции | Детали трейдера |
|
||||
| :----------------------------------------------------------: | :---------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
|
||||
|
||||
15+ моделей через [Claw402](https://claw402.ai) — только USDC кошелёк
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Strategy Studio</b></summary>
|
||||
|
||||
| Редактор стратегий | Настройка индикаторов |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Соревнование</b></summary>
|
||||
|
||||
| Режим соревнования |
|
||||
| :-------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -142,7 +163,7 @@ x402 процесс:
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (Облако)
|
||||
### Railway (облако)
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
@@ -153,35 +174,155 @@ curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Из исходников
|
||||
### 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
|
||||
```
|
||||
|
||||
### Сборка из исходников
|
||||
|
||||
```bash
|
||||
# Требования: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# 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 # бэкенд
|
||||
cd web && npm install && npm run dev # фронтенд (новый терминал)
|
||||
go build -o nofx && ./nofx
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
### Обновление
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Настройка
|
||||
|
||||
**Режим для новичков**: пошаговый onboarding проводит через доступ к моделям, подключение биржи, настройку стратегии и первый запуск.
|
||||
|
||||
**Продвинутый режим**:
|
||||
|
||||
1. Настройте доступ к AI-моделям
|
||||
2. Подключите учетные данные биржи
|
||||
3. Создайте или импортируйте стратегию
|
||||
4. Создайте профиль AI-трейдера
|
||||
5. Запустите, мониторьте и улучшайте через дашборд
|
||||
|
||||
Все настройки доступны в Web UI по адресу **http://127.0.0.1:3000**.
|
||||
|
||||
---
|
||||
|
||||
## Развёртывание на сервере
|
||||
|
||||
**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 вашего сервера (Proxied)
|
||||
3. SSL/TLS → Flexible
|
||||
4. Установите `TRANSPORT_ENCRYPTION=true` в `.env`
|
||||
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Документация
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| [Архитектура](../../architecture/README.md) | Дизайн системы и индекс модулей |
|
||||
| [Модуль стратегий](../../architecture/STRATEGY_MODULE.md) | Выбор инструментов, AI-промпты, исполнение |
|
||||
| [FAQ](../../faq/README.md) | Частые вопросы |
|
||||
| [Начало работы](../../getting-started/README.md) | Гайд по деплою |
|
||||
|
||||
---
|
||||
|
||||
## Участие
|
||||
|
||||
См. [Contributing Guide](../../../CONTRIBUTING.md), [Code of Conduct](../../../CODE_OF_CONDUCT.md) и [Security Policy](../../../SECURITY.md).
|
||||
|
||||
### Программа для контрибьюторов
|
||||
|
||||
NOFX отслеживает значимые вклады и планирует вознаграждать контрибьюторов по мере роста экосистемы. Приоритетные issues имеют более высокий вес награды.
|
||||
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## Ссылки
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| Сайт | [nofxai.com](https://nofxai.com) |
|
||||
| Панель | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API Документация | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| :--- | :--- |
|
||||
| Website | [vergex.trade](https://vergex.trade) |
|
||||
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
|
||||
|
||||
> **Предупреждение**: AI автоторговля несёт значительные риски. Рекомендуется только для обучения/исследований или тестирования малых сумм.
|
||||
> **Предупреждение о рисках**: автоматическая торговля связана со значительным риском. Контролируйте размер позиции, понимайте устройство каждой площадки и не торгуйте средствами, потерю которых не можете себе позволить.
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
## Спонсоры
|
||||
|
||||
<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)
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<p align="center"><strong>За підтримки <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
||||
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Ваш персональний AI торговий асистент.</strong><br/>
|
||||
<strong>Будь-який ринок. Будь-яка модель. Оплата USDC, без API ключів.</strong>
|
||||
<strong>AI-термінал для глобальних ринків.</strong><br/>
|
||||
<strong>Дослідження, генерація стратегій, виконання та моніторинг для акцій США, сировинних товарів, FX і криптоактивів.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,8 +17,6 @@
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,110 +31,131 @@
|
||||
|
||||
---
|
||||
|
||||
NOFX — це **автономний** AI торговий асистент з відкритим кодом. На відміну від традиційних AI інструментів, де потрібно вручну налаштовувати моделі, керувати API ключами та підключати джерела даних — AI у NOFX **сам аналізує ринки, обирає моделі та отримує дані**. Нульове втручання людини. Ви задаєте стратегію, AI робить все інше.
|
||||
NOFX — open-source AI-термінал для активних трейдерів, яким потрібен єдиний робочий простір для аналізу ринків, розробки стратегій, виконання угод і моніторингу портфеля.
|
||||
|
||||
**Повна автономність**: AI сам вирішує, яку модель використовувати, які ринкові дані отримати, коли торгувати. Без ручного налаштування моделей. Без жонглювання API ключами різних сервісів. Просто поповніть USDC гаманець і запустіть.
|
||||
|
||||
Ключова відмінність: **вбудовані [x402](https://x402.org) мікроплатежі**. Без API ключів. Поповніть USDC гаманець і платіть за кожен запит. Гаманець — це ваша ідентифікація.
|
||||
Продукт орієнтований на ліквідні глобальні ринки: акції США, товарні контракти, валютні пари й цифрові активи. AI-шар перетворює торговий намір на watchlists, сигнали, логіку стратегії, ризик-контроль і робочі процеси виконання.
|
||||
|
||||
```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** 을 엽니다.
|
||||
|
||||
---
|
||||
|
||||
## Швидке демо
|
||||
## Реєстрація на біржах
|
||||
|
||||
Скористайтеся посиланнями нижче, щоб відкрити торгові акаунти для крипторинків і підтримуваних деривативів на акції США, FX та сировинні товари. Ці маршрути належать до партнерських програм NOFX і можуть містити знижки на комісії або реферальні переваги.
|
||||
|
||||
| Біржа | Статус | Реєстрація зі знижкою |
|
||||
| :--- | :---: | :--- |
|
||||
| <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) |
|
||||
|
||||
---
|
||||
|
||||
## Коротка демонстрація
|
||||
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="Відео швидкого демо NOFX" width="900"/>
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Натисніть на зображення обкладинки, щоб переглянути демо-відео.
|
||||
Натисніть на обкладинку, щоб переглянути демо.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Як працює x402
|
||||
## Ринки
|
||||
|
||||
Традиційний процес: реєстрація → купівля кредитів → отримання API ключа → управління квотою → ротація ключів.
|
||||
**Акції США · Сировинні товари · FX · Криптоактиви**
|
||||
|
||||
x402 процес:
|
||||
NOFX организует исследования, построение стратегий, исполнение и мониторинг вокруг мультиактивных рабочих процессов, а не вокруг одного торгового экрана.
|
||||
|
||||
```
|
||||
Запит → 402 (ось ціна) → гаманець підписує USDC → повтор → готово
|
||||
```
|
||||
---
|
||||
|
||||
Без акаунтів. Без API ключів. Без передоплати. Один гаманець, усі моделі.
|
||||
## Доступ до AI-моделей
|
||||
|
||||
### Вбудовані x402 провайдери
|
||||
NOFX автоматично маршрутизує AI inference через [Claw402](https://claw402.ai). Користувачам не потрібно налаштовувати провайдерів моделей, керувати API-ключами або підтримувати окремі AI-акаунти. Термінал звертається до підтримуваних моделей на вимогу через pay-as-you-go інфраструктуру Claw402 та офіційний канал зі знижкою.
|
||||
|
||||
| Провайдер | Мережа | Моделі |
|
||||
|:---------|:------|:-------|
|
||||
| <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+ моделей |
|
||||
| Провайдер | Доступ |
|
||||
| :--- | :--- |
|
||||
| **Claw402** | [Доступ до AI-моделей pay-as-you-go з офіційною знижкою](https://claw402.ai) |
|
||||
|
||||
---
|
||||
|
||||
## Можливості
|
||||
|
||||
| Функція | Опис |
|
||||
|:--------|:------------|
|
||||
| **Мульти-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/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 гаманець
|
||||
| Возможность | Описание |
|
||||
| :--- | :--- |
|
||||
| **AI trading terminal** | Единое рабочее пространство для акций США, сырья, FX и криптоактивов |
|
||||
| **Доступ к AI-моделям** | Автоматический доступ к поддерживаемым провайдерам через Claw402 |
|
||||
| **Подключение бирж** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, Lighter |
|
||||
| **Strategy Studio** | Рыночные универсумы, индикаторы, риск-контроль и логика стратегий |
|
||||
| **Соревнование моделей** | Сравнение AI-трейдеров по live-результатам и таблице лидеров |
|
||||
| **Telegram Agent** | Управление и мониторинг ассистента через чат |
|
||||
| **Портфельный дашборд** | Позиции, P/L, история исполнения и логи решений модели |
|
||||
|
||||
---
|
||||
|
||||
## Встановлення
|
||||
## Скріншоти
|
||||
|
||||
<details>
|
||||
<summary><b>Сторінка налаштувань</b></summary>
|
||||
|
||||
| Конфігурація | Список трейдерів |
|
||||
| :----------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Дашборд</b></summary>
|
||||
|
||||
| Огляд | Графік ринку |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <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/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Strategy Studio</b></summary>
|
||||
|
||||
| Редактор стратегій | Налаштування індикаторів |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Змагання</b></summary>
|
||||
|
||||
| Режим змагання |
|
||||
| :-------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Установка
|
||||
|
||||
### Linux / macOS
|
||||
|
||||
@@ -142,7 +163,7 @@ x402 процес:
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (Хмара)
|
||||
### Railway (облако)
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
@@ -153,35 +174,155 @@ curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### З вихідного коду
|
||||
### 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
|
||||
```
|
||||
|
||||
### Сборка из исходников
|
||||
|
||||
```bash
|
||||
# Вимоги: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# 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 # бекенд
|
||||
cd web && npm install && npm run dev # фронтенд (новий термінал)
|
||||
go build -o nofx && ./nofx
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
### Обновление
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Настройка
|
||||
|
||||
**Режим для новичков**: пошаговый onboarding проводит через доступ к моделям, подключение биржи, настройку стратегии и первый запуск.
|
||||
|
||||
**Продвинутый режим**:
|
||||
|
||||
1. Настройте доступ к AI-моделям
|
||||
2. Подключите учетные данные биржи
|
||||
3. Создайте или импортируйте стратегию
|
||||
4. Создайте профиль AI-трейдера
|
||||
5. Запустите, мониторьте и улучшайте через дашборд
|
||||
|
||||
Все настройки доступны в Web UI по адресу **http://127.0.0.1:3000**.
|
||||
|
||||
---
|
||||
|
||||
## Розгортання на сервері
|
||||
|
||||
**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 вашого сервера (Proxied)
|
||||
3. SSL/TLS → Flexible
|
||||
4. Встановіть `TRANSPORT_ENCRYPTION=true` у `.env`
|
||||
|
||||
---
|
||||
|
||||
## Архітектура
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Документация
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| [Архитектура](../../architecture/README.md) | Дизайн системы и индекс модулей |
|
||||
| [Модуль стратегий](../../architecture/STRATEGY_MODULE.md) | Выбор инструментов, AI-промпты, исполнение |
|
||||
| [FAQ](../../faq/README.md) | Частые вопросы |
|
||||
| [Начало работы](../../getting-started/README.md) | Гайд по деплою |
|
||||
|
||||
---
|
||||
|
||||
## Участие
|
||||
|
||||
См. [Contributing Guide](../../../CONTRIBUTING.md), [Code of Conduct](../../../CODE_OF_CONDUCT.md) и [Security Policy](../../../SECURITY.md).
|
||||
|
||||
### Программа для контрибьюторов
|
||||
|
||||
NOFX отслеживает значимые вклады и планирует вознаграждать контрибьюторов по мере роста экосистемы. Приоритетные issues имеют более высокий вес награды.
|
||||
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## Посилання
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| Сайт | [nofxai.com](https://nofxai.com) |
|
||||
| Панель | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API Документація | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| :--- | :--- |
|
||||
| Website | [vergex.trade](https://vergex.trade) |
|
||||
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
|
||||
|
||||
> **Попередження**: AI автоторгівля несе значні ризики. Рекомендується лише для навчання/досліджень або тестування малих сум.
|
||||
> **Попередження про ризики**: автоматизована торгівля має значний ризик. Контролюйте розмір позиції, розумійте механіку кожного майданчика і не торгуйте коштами, втрату яких не можете собі дозволити.
|
||||
|
||||
---
|
||||
|
||||
## Ліцензія
|
||||
## Спонсори
|
||||
|
||||
<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)
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<p align="center"><strong>Được hỗ trợ bởi <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
||||
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
<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>
|
||||
<strong>Thiết bị đầu cuối giao dịch AI cho thị trường toàn cầu.</strong><br/>
|
||||
<strong>Nghiên cứu, tạo chiến lược, thực thi và giám sát cho cổ phiếu Mỹ, hàng hóa, ngoại hối và crypto.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,8 +17,6 @@
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,17 +31,33 @@
|
||||
|
||||
---
|
||||
|
||||
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à thiết bị đầu cuối giao dịch AI mã nguồn mở cho các trader cần một không gian làm việc thống nhất để nghiên cứu thị trường, phát triển chiến lược, thực thi giao dịch và giám sát danh mục.
|
||||
|
||||
**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.
|
||||
Sản phẩm tập trung vào các thị trường thanh khoản toàn cầu: cổ phiếu Mỹ, hợp đồng hàng hóa, cặp FX và tài sản số. Lớp AI chuyển ý định giao dịch thành watchlist, tín hiệu, logic chiến lược, kiểm soát rủi ro và workflow thực thi.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Mở **http://127.0.0.1:3000**. Xong.
|
||||
**http://127.0.0.1:3000** 을 엽니다.
|
||||
|
||||
---
|
||||
|
||||
## Đăng ký sàn giao dịch
|
||||
|
||||
Sử dụng các liên kết bên dưới để mở tài khoản giao dịch cho crypto và các thị trường phái sinh cổ phiếu Mỹ, FX, hàng hóa được hỗ trợ. Các tuyến này thuộc chương trình đối tác NOFX và có thể bao gồm ưu đãi phí hoặc quyền lợi giới thiệu.
|
||||
|
||||
| Sàn | 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) |
|
||||
| <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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -49,7 +65,7 @@ Mở **http://127.0.0.1:3000**. Xong.
|
||||
|
||||
<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"/>
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -59,76 +75,83 @@ Mở **http://127.0.0.1:3000**. Xong.
|
||||
|
||||
---
|
||||
|
||||
## x402 hoạt động như thế nào
|
||||
## Thị trường
|
||||
|
||||
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.
|
||||
**Cổ phiếu Mỹ · Hàng hóa · Ngoại hối · Crypto**
|
||||
|
||||
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 |
|
||||
NOFX tổ chức nghiên cứu, xây dựng chiến lược, thực thi và giám sát theo workflow đa tài sản thay vì một màn hình sàn đơn lẻ.
|
||||
|
||||
---
|
||||
|
||||
## Tính năng
|
||||
## Truy cập mô hình AI
|
||||
|
||||
| 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 |
|
||||
NOFX tự động định tuyến AI inference qua [Claw402](https://claw402.ai). Người dùng không cần cấu hình nhà cung cấp mô hình, quản lý API key hoặc duy trì tài khoản AI riêng. Terminal truy cập các mô hình được hỗ trợ theo nhu cầu qua hạ tầng pay-as-you-go của Claw402 và định tuyến qua kênh ưu đãi chính thức.
|
||||
|
||||
### Thị trường
|
||||
| Nhà cung cấp | Truy cập |
|
||||
| :--- | :--- |
|
||||
| **Claw402** | [Truy cập mô hình AI pay-as-you-go với ưu đãi chính thức](https://claw402.ai) |
|
||||
|
||||
Crypto · Cổ phiếu Mỹ · Forex · Kim loại
|
||||
---
|
||||
|
||||
### Sàn giao dịch (CEX)
|
||||
## Năng lực
|
||||
|
||||
| Sàn | Trạng thái | Đăng ký (Giảm 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/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) |
|
||||
| Năng lực | Mô tả |
|
||||
| :--- | :--- |
|
||||
| **AI trading terminal** | Không gian làm việc thống nhất cho cổ phiếu Mỹ, hàng hóa, ngoại hối và crypto |
|
||||
| **Truy cập mô hình AI** | Tự động kết nối nhà cung cấp được hỗ trợ qua Claw402 |
|
||||
| **Kết nối sàn** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, Lighter |
|
||||
| **Strategy Studio** | Universe thị trường, chỉ báo, kiểm soát rủi ro và logic chiến lược |
|
||||
| **Cạnh tranh mô hình** | So sánh AI trader bằng hiệu suất live và bảng xếp hạng |
|
||||
| **Telegram Agent** | Điều khiển và giám sát trợ lý giao dịch qua chat |
|
||||
| **Dashboard danh mục** | Vị thế, P/L, lịch sử thực thi và log quyết định của mô hình |
|
||||
|
||||
### 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/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Đăng ký](https://app.lighter.xyz/?referral=68151432) |
|
||||
## Ảnh chụp màn hình
|
||||
|
||||
### Mô hình AI (Chế độ API Key)
|
||||
<details>
|
||||
<summary><b>Trang cấu hình</b></summary>
|
||||
|
||||
| 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) |
|
||||
| Cấu hình | Danh sách trader |
|
||||
| :----------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
|
||||
|
||||
### Mô hình AI (Chế độ x402 — Không cần API Key)
|
||||
</details>
|
||||
|
||||
15+ mô hình qua [Claw402](https://claw402.ai) — chỉ cần ví USDC
|
||||
<details>
|
||||
<summary><b>Dashboard</b></summary>
|
||||
|
||||
| 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"/> |
|
||||
|
||||
| Vị thế | Chi tiết trader |
|
||||
| :----------------------------------------------------------: | :---------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Strategy Studio</b></summary>
|
||||
|
||||
| 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"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Cạnh tranh</b></summary>
|
||||
|
||||
| Chế độ cạnh tranh |
|
||||
| :-------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -151,34 +174,154 @@ curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod
|
||||
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 đó:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### Build từ source
|
||||
|
||||
```bash
|
||||
# Yêu cầu: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# 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 (terminal mới)
|
||||
go build -o nofx && ./nofx
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
### Cập nhật
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Thiết lập
|
||||
|
||||
**Chế độ người mới**: onboarding có hướng dẫn giúp người dùng mới hoàn tất truy cập mô hình, kết nối sàn, cấu hình chiến lược và lần triển khai đầu tiên.
|
||||
|
||||
**Chế độ nâng cao**:
|
||||
|
||||
1. Cấu hình truy cập mô hình AI
|
||||
2. Kết nối thông tin xác thực sàn
|
||||
3. Xây dựng hoặc import chiến lược
|
||||
4. Tạo hồ sơ AI trader
|
||||
5. Khởi chạy, giám sát và cải thiện từ dashboard
|
||||
|
||||
Tất cả cấu hình có trong Web UI tại **http://127.0.0.1:3000**.
|
||||
|
||||
---
|
||||
|
||||
## Triển khai lên máy chủ
|
||||
|
||||
**Triển khai HTTP:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# Truy cập qua 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ủ của bạn (Proxied)
|
||||
3. SSL/TLS → Flexible
|
||||
4. Đặt `TRANSPORT_ENCRYPTION=true` trong `.env`
|
||||
|
||||
---
|
||||
|
||||
## Kiến trúc
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tài liệu
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| [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 công cụ, prompt AI, thực thi |
|
||||
| [FAQ](../../faq/README.md) | Câu hỏi thường gặp |
|
||||
| [Bắt đầu](../../getting-started/README.md) | Hướng dẫn triển khai |
|
||||
|
||||
---
|
||||
|
||||
## Đóng góp
|
||||
|
||||
Xem [Contributing Guide](../../../CONTRIBUTING.md), [Code of Conduct](../../../CODE_OF_CONDUCT.md), và [Security Policy](../../../SECURITY.md).
|
||||
|
||||
### Chương trình contributor
|
||||
|
||||
NOFX ghi nhận các đóng góp có ý nghĩa và dự định thưởng cho contributor khi hệ sinh thái phát triển. Issue ưu tiên có trọng số thưởng cao hơn.
|
||||
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## Liên kết
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| 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) |
|
||||
| :--- | :--- |
|
||||
| Website | [vergex.trade](https://vergex.trade) |
|
||||
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
|
||||
|
||||
> **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ảnh báo rủi ro**: Giao dịch tự động có rủi ro đáng kể. Hãy dùng kích thước vị thế phù hợp, hiểu từng venue và không giao dịch số vốn bạn không thể mất.
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<p align="center"><strong>由 <a href="https://vergex.trade">vergex.trade</a> 支持</strong></p>
|
||||
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>你的个人 AI 交易助手。</strong><br/>
|
||||
<strong>任何市场。任何模型。用 USDC 付费,无需 API Key。</strong>
|
||||
<strong>面向全球市场的 AI 交易终端。</strong><br/>
|
||||
<strong>覆盖美股、大宗商品、外汇与加密市场的研究、策略生成、执行与监控。</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,8 +17,6 @@
|
||||
<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>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -33,17 +33,33 @@
|
||||
|
||||
---
|
||||
|
||||
NOFX 是一个开源的**自主式** AI 交易助手。与需要手动配置模型、管理 API Key、接入数据源的传统 AI 工具不同 —— NOFX 的 AI **自主感知市场、自选模型、自动获取数据**。零人工干预。你只需设定策略,AI 负责一切。
|
||||
NOFX 是一个开源 AI 交易终端,面向需要统一工作区完成市场研究、策略开发、交易执行与组合监控的活跃交易者。
|
||||
|
||||
**完全自主**:AI 自行决定使用哪个模型、获取什么市场数据、何时交易。无需手动配置模型,无需管理各种服务的 API Key。只需充值 USDC 钱包,一键启动。
|
||||
|
||||
核心差异:**内置 [x402](https://x402.org) 微支付协议**。无需 API Key,充值 USDC 钱包即可按需付费。钱包就是你的身份。
|
||||
产品围绕全球高流动性市场设计:美股、大宗商品合约、外汇货币对与数字资产。AI 层将交易意图转化为观察列表、信号、策略逻辑、风控约束与执行工作流。
|
||||
|
||||
```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**。
|
||||
|
||||
---
|
||||
|
||||
## 注册交易所
|
||||
|
||||
通过以下链接开通交易账户,可交易加密资产以及平台支持的美股、外汇和大宗商品衍生品市场。这些链接来自 NOFX 合作伙伴计划,可能包含手续费折扣或推荐权益。
|
||||
|
||||
| 交易所 | 状态 | 享手续费折扣注册 |
|
||||
| :--- | :---: | :--- |
|
||||
| <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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -51,87 +67,93 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX 快速演示视频" width="900"/>
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
点击封面图即可观看 Demo 视频。
|
||||
点击封面图观看演示视频。
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 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+ 模型 |
|
||||
NOFX 按多资产工作流组织研究、策略构建、执行与监控,而不是停留在单一交易所界面。
|
||||
|
||||
---
|
||||
|
||||
## 功能概览
|
||||
## AI 模型接入
|
||||
|
||||
| 功能 | 描述 |
|
||||
|:--------|:------------|
|
||||
| **多 AI** | DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi、MiniMax — 随时切换 |
|
||||
| **多交易所** | Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster、Lighter |
|
||||
| **策略工作室** | 可视化构建器 — 币种来源、指标、风控 |
|
||||
| **AI 竞赛** | AI 实时竞争,排行榜排名 |
|
||||
| **Telegram Agent** | 与交易助手对话 — 流式输出、工具调用、记忆 |
|
||||
| **回测实验室** | 历史模拟,权益曲线和性能指标 |
|
||||
| **仪表板** | 实时持仓、盈亏、AI 决策日志与思维链 |
|
||||
NOFX 自动通过 [Claw402](https://claw402.ai) 路由 AI 推理请求。用户无需配置大模型供应商、管理 API Key 或维护独立 AI 账户。终端按需按次调用 Claw402 的 AI 模型基础设施,并通过官方折扣通道完成路由。
|
||||
|
||||
### 市场
|
||||
| 提供商 | 接入 |
|
||||
| :--- | :--- |
|
||||
| **Claw402** | [通过官方折扣通道按需使用 AI 模型](https://claw402.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/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) |
|
||||
| 能力 | 描述 |
|
||||
| :--- | :--- |
|
||||
| **AI 交易终端** | 面向美股、大宗商品、外汇与加密资产的一体化工作区 |
|
||||
| **AI 模型接入** | 通过 Claw402 自动接入支持的模型供应商 |
|
||||
| **交易所连接** | Binance、Bybit、OKX、Hyperliquid、Bitget、KuCoin、Gate、Aster、Lighter |
|
||||
| **策略工作室** | 市场范围、指标、风控与策略逻辑 |
|
||||
| **模型竞赛** | 比较 AI 交易员的实时表现与排行榜 |
|
||||
| **Telegram Agent** | 通过聊天控制和监控交易助手 |
|
||||
| **组合仪表板** | 持仓、盈亏、执行历史与模型决策日志 |
|
||||
|
||||
### 交易所 (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 Key 模式)
|
||||
<details>
|
||||
<summary><b>配置页</b></summary>
|
||||
|
||||
| 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) |
|
||||
| 配置 | 交易员列表 |
|
||||
| :----------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
|
||||
|
||||
### AI 模型 (x402 模式 — 无需 API Key)
|
||||
</details>
|
||||
|
||||
15+ 模型通过 [Claw402](https://claw402.ai) 接入 — 只需一个 USDC 钱包
|
||||
<details>
|
||||
<summary><b>仪表板</b></summary>
|
||||
|
||||
| 概览 | 行情图表 |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <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/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>策略工作室</b></summary>
|
||||
|
||||
| 策略编辑器 | 指标配置 |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>竞赛</b></summary>
|
||||
|
||||
| 竞赛模式 |
|
||||
| :-------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -143,7 +165,7 @@ x402 流程:
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (云部署)
|
||||
### Railway(云部署)
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
@@ -166,13 +188,13 @@ docker compose -f docker-compose.prod.yml up -d
|
||||
### 从源码构建
|
||||
|
||||
```bash
|
||||
# 前置条件: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# 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 # 后端
|
||||
cd web && npm install && npm run dev # 前端 (新终端)
|
||||
go build -o nofx && ./nofx
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
### 更新
|
||||
@@ -185,24 +207,68 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
|
||||
## 配置
|
||||
|
||||
**新手模式**:首次使用的用户可以在注册时选择新手模式,系统会引导你逐步完成 AI、交易所和策略的配置。
|
||||
**新手模式**:引导式 onboarding 帮助新用户完成模型访问、交易所连接、策略配置与首次部署。
|
||||
|
||||
**进阶模式**:
|
||||
|
||||
1. **AI** — 添加 API Key 或配置 x402 钱包
|
||||
2. **交易所** — 连接交易所 API 凭证
|
||||
3. **策略** — 在策略工作室构建
|
||||
4. **交易员** — 组合 AI + 交易所 + 策略
|
||||
5. **交易** — 从仪表板启动
|
||||
1. 配置 AI 模型访问
|
||||
2. 连接交易所凭证
|
||||
3. 构建或导入策略
|
||||
4. 创建 AI 交易员配置
|
||||
5. 在仪表板启动、监控并迭代
|
||||
|
||||
所有操作通过 Web 界面完成:**http://127.0.0.1:3000**
|
||||
所有配置均可在 Web UI **http://127.0.0.1:3000** 完成。
|
||||
|
||||
---
|
||||
|
||||
## 部署到服务器
|
||||
|
||||
**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`
|
||||
|
||||
---
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| :--- | :--- |
|
||||
| [架构概览](../../architecture/README.md) | 系统设计和模块索引 |
|
||||
| [策略模块](../../architecture/STRATEGY_MODULE.md) | 币种选择、AI 提示词、执行 |
|
||||
| [常见问题](../../faq/README.md) | FAQ |
|
||||
@@ -212,39 +278,52 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
|
||||
## 贡献
|
||||
|
||||
查看 [贡献指南](../../../CONTRIBUTING.md) · [行为准则](../../../CODE_OF_CONDUCT.md) · [安全政策](../../../SECURITY.md)
|
||||
查看 [贡献指南](../../../CONTRIBUTING.md)、[行为准则](../../../CODE_OF_CONDUCT.md) 与 [安全政策](../../../SECURITY.md)。
|
||||
|
||||
### 贡献者空投计划
|
||||
### 贡献者计划
|
||||
|
||||
所有贡献在 GitHub 上追踪。当 NOFX 产生收入时,贡献者将获得空投。
|
||||
NOFX 会记录有价值的贡献,并计划在生态增长后回馈贡献者。优先级 Issue 拥有更高奖励权重。
|
||||
|
||||
**解决 [置顶 Issue](https://github.com/NoFxAiOS/nofx/issues) 的 PR 获得最高奖励!**
|
||||
|
||||
| 贡献类型 | 权重 |
|
||||
|:-------------|:------:|
|
||||
| 置顶 Issue PR | ★★★★★★ |
|
||||
| 代码提交 (合并的 PR) | ★★★★★ |
|
||||
| Bug 修复 | ★★★★ |
|
||||
| 功能建议 | ★★★ |
|
||||
| Bug 报告 | ★★ |
|
||||
| 文档 | ★★ |
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## 链接
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| 官网 | [nofxai.com](https://nofxai.com) |
|
||||
| 数据面板 | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API 文档 | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| :--- | :--- |
|
||||
| 官网 | [vergex.trade](https://vergex.trade) |
|
||||
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
|
||||
|
||||
> **风险提示**: AI 自动交易存在重大风险。建议仅用于学习/研究或小额测试。
|
||||
> **风险提示**:自动化交易存在重大风险。请控制仓位,理解每个交易场所的机制,不要投入无法承受损失的资金。
|
||||
|
||||
---
|
||||
|
||||
## 赞助者
|
||||
|
||||
<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)
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
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=
|
||||
|
||||
563
kernel/engine.go
563
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
|
||||
@@ -182,8 +186,10 @@ type OIDeltaData struct {
|
||||
|
||||
// StrategyEngine strategy execution engine
|
||||
type StrategyEngine struct {
|
||||
config *store.StrategyConfig
|
||||
nofxosClient *nofxos.Client
|
||||
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,
|
||||
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,210 @@ 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
|
||||
}
|
||||
|
||||
items := make([]vergex.SignalRankItem, 0, limit)
|
||||
for _, item := range rankedItems {
|
||||
if category != "" && category != "all" && item.Category != category {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
if len(items) >= limit {
|
||||
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
|
||||
}
|
||||
|
||||
func withDefaultText(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// External & Quant Data
|
||||
// ============================================================================
|
||||
@@ -677,6 +938,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 +1032,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 +1349,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 +1385,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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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,45 @@ 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()
|
||||
|
||||
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("# 你是一名专业的 Hyperliquid USDC 多资产交易 AI\n\n")
|
||||
sb.WriteString("你的任务是基于提供的市场数据做出交易决策。\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 +67,641 @@ 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("# ⏱️ 交易频率提醒\n\n")
|
||||
sb.WriteString("- 优秀交易员: 每日 2-4 单 ≈ 每小时 0.1-0.2 单\n")
|
||||
sb.WriteString("- 每小时 > 2 单 = 过度交易\n")
|
||||
sb.WriteString("- 单笔持仓时长 ≥ 45-90 分钟\n")
|
||||
sb.WriteString("如果你发现自己每个周期都在交易 → 入场标准过低; 如果不到 45 分钟就平仓 → 太冲动。\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)
|
||||
sb.WriteString("\n\nYou have the following indicator data:\n")
|
||||
e.writeAvailableIndicators(&sb)
|
||||
sb.WriteString(fmt.Sprintf("\n**Confidence ≥ %d** required to open positions.\n\n", riskControl.MinConfidence))
|
||||
entryStandards := englishOnlyPromptSection(promptSections.EntryStandards)
|
||||
if entryStandards != "" {
|
||||
sb.WriteString(entryStandards)
|
||||
if zh {
|
||||
sb.WriteString("\n\n你拥有以下指标数据:\n")
|
||||
} else {
|
||||
sb.WriteString("\n\nYou have the following indicator data:\n")
|
||||
}
|
||||
e.writeAvailableIndicators(&sb, zh)
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("\n**置信度 ≥ %d** 才能开仓。\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("# 🎯 入场标准 (严格)\n\n")
|
||||
sb.WriteString("只有当多重信号共振时才开仓。你拥有:\n")
|
||||
e.writeAvailableIndicators(&sb, zh)
|
||||
sb.WriteString(fmt.Sprintf("\n请自由使用任何有效的分析方法, 但**置信度 ≥ %d** 才能开仓; 避免低质量行为, 如单一指标、信号矛盾、横盘震荡、平仓后立刻再开等。\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("# 📋 决策流程\n\n")
|
||||
sb.WriteString("1. 检查持仓 → 是否需要止盈止损\n")
|
||||
sb.WriteString("2. 扫描候选标的 + 多周期 → 是否有强信号\n")
|
||||
sb.WriteString("3. 先写思维链, 再输出结构化 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 != "" {
|
||||
sb.WriteString("# 📌 Personalized Trading Strategy\n\n")
|
||||
sb.WriteString(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 singleSymbol && market.IsXyzDexAsset(primarySymbol) {
|
||||
customPrompt = buildXYZStockCustomPrompt(primarySymbol)
|
||||
}
|
||||
|
||||
if customPrompt != "" {
|
||||
if zh {
|
||||
sb.WriteString("# 📌 个性化交易策略\n\n")
|
||||
} 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("说明: 上述个性化策略是基础规则的补充, 不能违反基础风控原则。\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("# 你是 NOFX Claw402 自动交易员\n\n")
|
||||
sb.WriteString("你的任务是交易 Claw402.ai/Vergex 本轮榜单返回的 Hyperliquid 可交易标的。只允许交易本轮候选标的和已有持仓,不要自行发明代码或切换到榜单外标的。\n\n")
|
||||
sb.WriteString("# 决策数据优先级\n\n")
|
||||
sb.WriteString("1. Claw402.ai Signal Ranking: 决定本轮候选池、排名、方向和类别。\n")
|
||||
sb.WriteString("2. Claw402.ai Signal Lab: 用于确认趋势、动量、事件或模型信号;这是开仓前的核心确认数据。\n")
|
||||
sb.WriteString("3. Claw402.ai Cost/Liquidation Heatmap: 用于识别清算密集区、成本区、止损位置和止盈目标。\n")
|
||||
sb.WriteString("4. 原始 OHLCV K 线: 用于验证入场时机、趋势结构、波动和风险回报。\n\n")
|
||||
sb.WriteString("# 交易原则\n\n")
|
||||
sb.WriteString("- 先管理已有持仓,再考虑新开仓。\n")
|
||||
sb.WriteString("- 开仓需要 Signal Lab、热力图和 K 线方向大体一致;任一关键数据缺失或互相冲突时,默认等待。\n")
|
||||
sb.WriteString("- 不要把 Claw402 排名当作唯一买入理由;排名只是候选池,开仓必须经过详情数据和 K 线确认。\n")
|
||||
sb.WriteString("- 本轮 Candidate Coins 中的标的都是允许交易的候选;如果某个标的详情缺失,只能降低置信度或等待,不能说它不属于可交易范围。\n")
|
||||
sb.WriteString("- 如果 Signal Lab 或热力图没有出现在该标的的 Vergex Claw402 Signals 里,必须在 reasoning 中说明缺失;如果已经出现,则不能声称该标的缺少该数据。\n")
|
||||
sb.WriteString("- 防止频繁开平仓:非止损或强止盈情况下,开仓后至少持有 45 分钟;小亏小赚的噪音区优先持有到 90 分钟;平仓后同一标的 90 分钟内不重新进场;每小时最多 1 次新开仓。\n")
|
||||
sb.WriteString("- 止损必须放在无效点之外;止盈优先放在热力图阻力/清算区域或满足风险回报的位置。\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("- Avoid churn: unless stopping out or taking a strong profit, hold new positions for at least 45 minutes; avoid flat/noise closes until roughly 90 minutes; after closing a symbol, wait 90 minutes before re-entry; open at most 1 new position per hour.\n")
|
||||
sb.WriteString("- Stops must sit beyond invalidation; targets should prefer heatmap resistance/liquidation zones or valid risk/reward levels.\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 := englishOnlyPromptSection(e.config.CustomPrompt)
|
||||
if customPrompt != "" {
|
||||
sb.WriteString("# User Preference\n\n")
|
||||
sb.WriteString(customPrompt)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
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 数据说明\n\n")
|
||||
sb.WriteString("- Equity: 账户总权益,包含浮动盈亏,单位 USDT。\n")
|
||||
sb.WriteString("- Balance: 可用余额,用于判断还能否开新仓,单位 USDT。\n")
|
||||
sb.WriteString("- Margin: 当前保证金使用率,越高风险越大。\n")
|
||||
sb.WriteString("- Position: 当前持仓,包含方向、进场价、杠杆、未实现盈亏、强平价。\n")
|
||||
sb.WriteString("- Claw402 Ranking: 本轮可交易候选池、排名、方向和类别。\n")
|
||||
sb.WriteString("- Signal Lab: Claw402 对单个标的的深度信号,用于确认趋势和质量。\n")
|
||||
sb.WriteString("- Cost/Liquidation Heatmap: 成本区与清算密集区,用于止损、止盈和拥挤风险判断。\n")
|
||||
sb.WriteString("- Raw OHLCV Kline: 原始 K 线,用于确认趋势结构、入场位置和风险回报。\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("# 风控硬约束\n\n")
|
||||
sb.WriteString("## 后端强制\n")
|
||||
sb.WriteString(fmt.Sprintf("- 最大持仓数: 同时 %d 个 Claw402 候选标的\n", riskControl.MaxPositions))
|
||||
sb.WriteString(fmt.Sprintf("- 单仓最大名义价值: %.0f USDT (= 权益 %.0f × %.1fx)\n", maxPositionValue, accountEquity, tradeFiPositionValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- 最大保证金占用: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- 最小下单金额: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
sb.WriteString("## AI 建议\n")
|
||||
sb.WriteString(fmt.Sprintf("- 交易杠杆: Claw402 候选标的最高 %dx\n", riskControl.AltcoinMaxLeverage))
|
||||
sb.WriteString(fmt.Sprintf("- 风险回报比: ≥1:%.1f\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- 最小置信度: ≥%d 才能开仓\n\n", riskControl.MinConfidence))
|
||||
sb.WriteString("# 仓位大小\n\n")
|
||||
sb.WriteString("根据置信度和单仓最大名义价值填写 `position_size_usd`:\n")
|
||||
sb.WriteString("- 高置信 (≥85): 使用上限的 80-100%\n")
|
||||
sb.WriteString("- 中置信 (70-84): 使用上限的 50-80%\n")
|
||||
sb.WriteString("- 低置信 (60-69): 使用上限的 30-50%\n")
|
||||
sb.WriteString("- 不要直接把 available_balance 当作 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("必须使用 XML 标签 <reasoning> 和 <decision> 分隔简明分析和决策 JSON。\n\n")
|
||||
sb.WriteString("方向必须由数据决定:上涨结构确认时可以 `open_long`,下跌结构确认时可以 `open_short`;不要默认只做多或只做空。\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")
|
||||
}
|
||||
sb.WriteString("<reasoning>\n")
|
||||
if zh {
|
||||
sb.WriteString("简明说明: Claw402 排名、Signal Lab、热力图、K 线是否一致;如果缺数据或冲突,说明为什么等待。\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("## 字段要求\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100,开仓建议 ≥ %d\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- 开仓时必填: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- 所有数值必须是算好的数字,不能写公式。\n")
|
||||
if singleSymbol {
|
||||
sb.WriteString(fmt.Sprintf("- 本策略只交易 `%s`,JSON 的 symbol 必须完全等于它。\n", exampleSymbol))
|
||||
} else {
|
||||
sb.WriteString("- JSON 的 symbol 必须完全来自本轮候选标的或已有持仓;`xyz:` 标的保留前缀,core crypto 标的不要添加 `xyz:` 或 `USDT` 后缀。\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("## 模式: 激进\n- 优先捕捉趋势突破, 置信度 ≥ 70 时可分批建仓\n- 允许更高仓位, 但必须严格止损并说明风险回报比\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("## 模式: 保守\n- 只有当多重信号共振时才开仓\n- 优先保本, 连亏后必须暂停多个周期\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("## 模式: 短线\n- 关注短期动量, 利润目标较小但要求迅速行动\n- 价格两根 K 线内未按预期走 → 立即减仓或止损\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("# 风控硬约束\n\n")
|
||||
sb.WriteString("## 代码强制 (后端校验, 无法绕过):\n")
|
||||
sb.WriteString(fmt.Sprintf("- 最大持仓数: 同时 %d 个标的\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("- 单仓最大价值 (%s): %.0f USDT (= 权益 %.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("- 单仓最大价值 (山寨币/股票): %.0f USDT (= 权益 %.0f × %.1fx)\n", accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- 单仓最大价值 (BTC/ETH): %.0f USDT (= 权益 %.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("- 最大保证金占用: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- 最小下单金额: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
sb.WriteString("## AI 建议 (推荐遵循):\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("- 交易杠杆 (%s): 最高 %dx\n", primarySymbol, lev))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage (%s): max %dx\n", primarySymbol, lev))
|
||||
}
|
||||
} else {
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- 交易杠杆: 山寨币/股票 最高 %dx | BTC/ETH 最高 %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("- 风险回报比: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- 最小置信度: ≥%d 才开仓\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("## 仓位大小指引\n")
|
||||
sb.WriteString("根据置信度和上面的单仓最大价值算出 `position_size_usd`:\n")
|
||||
sb.WriteString("- 高置信 (≥85): 用最大价值的 80-100%%\n")
|
||||
sb.WriteString("- 中置信 (70-84): 用最大价值的 50-80%%\n")
|
||||
sb.WriteString("- 低置信 (60-69): 用最大价值的 30-50%%\n")
|
||||
sb.WriteString(fmt.Sprintf("- 示例: 权益 %.0f × %.1fx = 最大 %.0f USDT\n", accountEquity, exampleRatio, accountEquity*exampleRatio))
|
||||
sb.WriteString("- **不要**直接拿 available_balance 当 position_size_usd, 用上面的单仓最大价值!\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("**必须使用 XML 标签 <reasoning> 和 <decision> 分隔思维链和决策 JSON, 避免解析错误**\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("你的思维链分析...\n- 简明分析你的思考过程\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("步骤 2: JSON 决策数组\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("## 字段说明\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (开仓建议 ≥ %d)\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- 开仓时必填: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- **重要**: 所有数值必须是算好的数字, 不能是公式/表达式 (例如写 `27.76`, 不要写 `3000 * 0.01`)\n")
|
||||
if singleSymbol {
|
||||
sb.WriteString(fmt.Sprintf("- **本策略只交易 %s**, JSON 中的 `symbol` 必须**完全等于** `%s`, 不要写成 `%s` 去掉后缀或加 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
|
||||
|
||||
sb.WriteString(fmt.Sprintf("- %s price series", kline.PrimaryTimeframe))
|
||||
if kline.EnableMultiTimeframe {
|
||||
sb.WriteString(fmt.Sprintf(" + %s K-line series\n", kline.LongerTimeframe))
|
||||
label := func(en, zhStr string) string {
|
||||
if zh {
|
||||
return zhStr
|
||||
}
|
||||
return en
|
||||
}
|
||||
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- %s 价格序列", kline.PrimaryTimeframe))
|
||||
if kline.EnableMultiTimeframe {
|
||||
sb.WriteString(fmt.Sprintf(" + %s K 线序列\n", kline.LongerTimeframe))
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
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 指标"))
|
||||
if len(indicators.EMAPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.EMAPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "周期"), indicators.EMAPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableMACD {
|
||||
sb.WriteString("- MACD indicators\n")
|
||||
sb.WriteString("- " + label("MACD indicators", "MACD 指标") + "\n")
|
||||
}
|
||||
|
||||
if indicators.EnableRSI {
|
||||
sb.WriteString("- RSI indicators")
|
||||
sb.WriteString("- " + label("RSI indicators", "RSI 指标"))
|
||||
if len(indicators.RSIPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.RSIPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "周期"), indicators.RSIPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableATR {
|
||||
sb.WriteString("- ATR indicators")
|
||||
sb.WriteString("- " + label("ATR indicators", "ATR 指标"))
|
||||
if len(indicators.ATRPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.ATRPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("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", "布林带 (BOLL) - 上/中/下轨"))
|
||||
if len(indicators.BOLLPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.BOLLPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "周期"), indicators.BOLLPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableVolume {
|
||||
sb.WriteString("- Volume data\n")
|
||||
sb.WriteString("- " + label("Volume data", "成交量数据") + "\n")
|
||||
}
|
||||
|
||||
if indicators.EnableOI {
|
||||
sb.WriteString("- Open Interest (OI) data\n")
|
||||
sb.WriteString("- " + label("Open Interest (OI) data", "持仓量 (OI) 数据") + "\n")
|
||||
}
|
||||
|
||||
if indicators.EnableFundingRate {
|
||||
sb.WriteString("- Funding rate\n")
|
||||
sb.WriteString("- " + label("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 过滤标记 (如有)") + "\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)", "量化数据 (机构/散户资金流, 持仓变化, 多周期价格变动)") + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +853,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 +884,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 +923,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 +986,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
|
||||
// ============================================================================
|
||||
|
||||
124
kernel/engine_prompt_test.go
Normal file
124
kernel/engine_prompt_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
func TestBuildSystemPromptUsesVergexClaw402Prompt(t *testing.T) {
|
||||
cfg := store.GetDefaultStrategyConfig("zh")
|
||||
cfg.CoinSource.SourceType = "vergex_signal"
|
||||
cfg.CoinSource.VergexLimit = 5
|
||||
cfg.PromptSections.RoleDefinition = "# 你是一个专业的 Hyperliquid USDC 多资产交易AI"
|
||||
cfg.CustomPrompt = "只做多,不做空。"
|
||||
|
||||
engine := NewStrategyEngine(&cfg)
|
||||
prompt := engine.BuildSystemPrompt(30, "balanced")
|
||||
|
||||
if !strings.Contains(prompt, "NOFX Claw402 auto-trader") {
|
||||
t.Fatalf("prompt did not use the Claw402/Vergex TradeFi role:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Claw402.ai Signal Ranking") || !strings.Contains(prompt, "Signal Lab") || !strings.Contains(prompt, "Cost/Liquidation Heatmap") {
|
||||
t.Fatalf("prompt is missing Claw402/Vergex detail data guidance:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "open_short") {
|
||||
t.Fatalf("prompt should explicitly allow short entries:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Direction must be data-driven") {
|
||||
t.Fatalf("prompt should explain that direction is data-driven, not long-only:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "every open position must use exactly 10x") {
|
||||
t.Fatalf("prompt should force 10x leverage for Claw402 opens:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "use the full max notional per position") {
|
||||
t.Fatalf("prompt should force full-size Claw402 opens:\n%s", prompt)
|
||||
}
|
||||
if containsCJK(prompt) {
|
||||
t.Fatalf("system prompt must be English-only, got CJK text:\n%s", prompt)
|
||||
}
|
||||
legacyPhrases := []string{
|
||||
"Hyperliquid USDC 多资产交易AI",
|
||||
"只做多",
|
||||
"山寨币",
|
||||
"BTC/ETH",
|
||||
"LONG-ONLY",
|
||||
"Do not short",
|
||||
"MUST open a long",
|
||||
}
|
||||
for _, phrase := range legacyPhrases {
|
||||
if strings.Contains(prompt, phrase) {
|
||||
t.Fatalf("prompt still contains legacy phrase %q:\n%s", phrase, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptFallsBackToEnglishWhenConfiguredLanguageIsChinese(t *testing.T) {
|
||||
cfg := store.GetDefaultStrategyConfig("zh")
|
||||
cfg.CoinSource.SourceType = "static"
|
||||
cfg.CoinSource.StaticCoins = []string{"BTCUSDT", "ETHUSDT"}
|
||||
cfg.CoinSource.VergexLimit = 0
|
||||
cfg.CoinSource.VergexMarketType = ""
|
||||
cfg.CoinSource.VergexChain = ""
|
||||
cfg.PromptSections.RoleDefinition = "# 你是中文系统提示词"
|
||||
cfg.PromptSections.TradingFrequency = "# 高频交易\n每分钟交易。"
|
||||
cfg.PromptSections.EntryStandards = "# 入场\n随便开仓。"
|
||||
cfg.PromptSections.DecisionProcess = "# 决策\n直接输出。"
|
||||
cfg.CustomPrompt = "中文偏好不应进入系统提示词。"
|
||||
|
||||
engine := NewStrategyEngine(&cfg)
|
||||
prompt := engine.BuildSystemPrompt(30, "balanced")
|
||||
|
||||
required := []string{
|
||||
"Data Dictionary & Trading Rules",
|
||||
"You are a professional Hyperliquid USDC multi-asset trading AI",
|
||||
"Trading Frequency Awareness",
|
||||
"Entry Standards",
|
||||
"Decision Process",
|
||||
}
|
||||
for _, phrase := range required {
|
||||
if !strings.Contains(prompt, phrase) {
|
||||
t.Fatalf("English fallback prompt missing %q:\n%s", phrase, prompt)
|
||||
}
|
||||
}
|
||||
if containsCJK(prompt) {
|
||||
t.Fatalf("system prompt must be English-only, got CJK text:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptDoesNotForceLongOnlyForSingleXYZ(t *testing.T) {
|
||||
prompt := buildXYZStockCustomPrompt("XYZ:INTC")
|
||||
|
||||
required := []string{
|
||||
"DIRECTIONAL, SIGNAL-DRIVEN",
|
||||
"You may open long or short",
|
||||
"open_short",
|
||||
}
|
||||
for _, phrase := range required {
|
||||
if !strings.Contains(prompt, phrase) {
|
||||
t.Fatalf("single XYZ prompt missing %q:\n%s", phrase, prompt)
|
||||
}
|
||||
}
|
||||
|
||||
forbidden := []string{
|
||||
"LONG-ONLY",
|
||||
"Do not short",
|
||||
"MUST open a long",
|
||||
"Probing > waiting",
|
||||
}
|
||||
for _, phrase := range forbidden {
|
||||
if strings.Contains(prompt, phrase) {
|
||||
t.Fatalf("single XYZ prompt still contains forced-long phrase %q:\n%s", phrase, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsCJK(text string) bool {
|
||||
for _, r := range text {
|
||||
if r >= 0x4E00 && r <= 0x9FFF {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
107
kernel/engine_vergex_test.go
Normal file
107
kernel/engine_vergex_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"nofx/provider/vergex"
|
||||
)
|
||||
|
||||
func TestVergexDetailQueryCandidatesUseHIP3MarketAndMainnetChain(t *testing.T) {
|
||||
candidates := vergexDetailQueryCandidates(vergex.Query{
|
||||
MarketType: vergex.DefaultMarketType,
|
||||
Symbol: "xyz:INTC",
|
||||
Chain: vergex.DefaultChain,
|
||||
Category: "stock",
|
||||
})
|
||||
|
||||
if len(candidates) == 0 {
|
||||
t.Fatal("expected detail query candidates")
|
||||
}
|
||||
if candidates[0].MarketType != "hip3_perp" || candidates[0].Chain != "mainnet" {
|
||||
t.Fatalf("first candidate = %+v, want hip3_perp/mainnet", candidates[0])
|
||||
}
|
||||
|
||||
if !hasVergexDetailCandidate(candidates, "hip3_perp", "") {
|
||||
t.Fatalf("expected hip3_perp/default-chain fallback in %+v", candidates)
|
||||
}
|
||||
if hasVergexDetailCandidate(candidates, "stock", "mainnet") {
|
||||
t.Fatalf("did not expect stock marketType fallback for Vergex detail endpoint: %+v", candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVergexDetailSymbolForLookupKeepsCoreCryptoBaseSymbols(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
marketType string
|
||||
symbol string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "core crypto from all board",
|
||||
marketType: "all",
|
||||
symbol: "AAVE",
|
||||
want: "AAVE",
|
||||
},
|
||||
{
|
||||
name: "core crypto with usdt suffix",
|
||||
marketType: "all",
|
||||
symbol: "HYPEUSDT",
|
||||
want: "HYPE",
|
||||
},
|
||||
{
|
||||
name: "xyz stock keeps xyz prefix",
|
||||
marketType: "all",
|
||||
symbol: "xyz:INTC",
|
||||
want: "xyz:INTC",
|
||||
},
|
||||
{
|
||||
name: "hip3 symbol gains xyz prefix",
|
||||
marketType: vergex.DefaultMarketType,
|
||||
symbol: "SNDK",
|
||||
want: "xyz:SNDK",
|
||||
},
|
||||
{
|
||||
name: "core market strips suffix",
|
||||
marketType: "core_perp",
|
||||
symbol: "LITUSDT",
|
||||
want: "LIT",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := vergexDetailSymbolForLookup(tc.marketType, tc.symbol); got != tc.want {
|
||||
t.Fatalf("vergexDetailSymbolForLookup(%q, %q) = %q, want %q", tc.marketType, tc.symbol, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVergexDetailQueryCandidatesPreferMarketTypeBySymbolWhenSourceIsAll(t *testing.T) {
|
||||
cryptoCandidates := vergexDetailQueryCandidates(vergex.Query{
|
||||
MarketType: "all",
|
||||
Symbol: "AAVE",
|
||||
Chain: "mainnet",
|
||||
})
|
||||
if len(cryptoCandidates) == 0 || cryptoCandidates[0].MarketType != "core_perp" {
|
||||
t.Fatalf("crypto candidates should prefer core_perp first: %+v", cryptoCandidates)
|
||||
}
|
||||
|
||||
xyzCandidates := vergexDetailQueryCandidates(vergex.Query{
|
||||
MarketType: "all",
|
||||
Symbol: "xyz:SNDK",
|
||||
Chain: "mainnet",
|
||||
})
|
||||
if len(xyzCandidates) == 0 || xyzCandidates[0].MarketType != vergex.DefaultMarketType {
|
||||
t.Fatalf("xyz candidates should prefer hip3_perp first: %+v", xyzCandidates)
|
||||
}
|
||||
}
|
||||
|
||||
func hasVergexDetailCandidate(candidates []vergex.Query, marketType, chain string) bool {
|
||||
for _, candidate := range candidates {
|
||||
if candidate.MarketType == marketType && candidate.Chain == chain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
15
kernel/hyperliquid_rank_test.go
Normal file
15
kernel/hyperliquid_rank_test.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package kernel
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClampHyperRankLimit(t *testing.T) {
|
||||
if got := clampHyperRankLimit(0); got != 5 {
|
||||
t.Fatalf("clamp 0 = %d, want 5", got)
|
||||
}
|
||||
if got := clampHyperRankLimit(99); got != 10 {
|
||||
t.Fatalf("clamp 99 = %d, want 10", got)
|
||||
}
|
||||
if got := clampHyperRankLimit(3); got != 3 {
|
||||
t.Fatalf("clamp 3 = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,20 @@ func TestLeverageFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaw402XyzAllowsFullTenXNotional(t *testing.T) {
|
||||
decision := Decision{
|
||||
Symbol: "xyz:SP500",
|
||||
Action: "open_long",
|
||||
Leverage: 10,
|
||||
PositionSizeUSD: 306.8,
|
||||
StopLoss: 95,
|
||||
TakeProfit: 120,
|
||||
}
|
||||
|
||||
if err := validateDecision(&decision, 30.68, 10, 10, 10.0, 10.0); err != nil {
|
||||
t.Fatalf("xyz TradeFi Claw402 full 10x notional should pass validation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// contains checks if string contains substring (helper function)
|
||||
func contains(s, substr string) bool {
|
||||
|
||||
42
main.go
42
main.go
@@ -1,19 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"nofx/api"
|
||||
nofxiagent "nofx/agent"
|
||||
"nofx/auth"
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
"nofx/logger"
|
||||
"nofx/manager"
|
||||
"nofx/telemetry"
|
||||
_ "nofx/mcp/payment"
|
||||
_ "nofx/mcp/provider"
|
||||
"nofx/store"
|
||||
"nofx/telegram"
|
||||
"nofx/telemetry"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -24,6 +21,14 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Local admin subcommands (account recovery) run directly against the
|
||||
// database and never start the HTTP server. Recovery therefore requires
|
||||
// shell/file access to the host instead of a network request, which keeps
|
||||
// it safe even when NOFX is exposed to the public internet. See cli.go.
|
||||
if runCLISubcommand(os.Args[1:]) {
|
||||
return
|
||||
}
|
||||
|
||||
// Load .env environment variables
|
||||
_ = godotenv.Load()
|
||||
|
||||
@@ -34,8 +39,9 @@ func main() {
|
||||
logger.Info("║ 🚀 NOFX - AI-Powered Trading System ║")
|
||||
logger.Info("╚════════════════════════════════════════════════════════════╝")
|
||||
|
||||
// Initialize global configuration (loaded from .env)
|
||||
config.Init()
|
||||
// Initialize global configuration (loaded from .env).
|
||||
// MustInit refuses to start under an insecure config (e.g. missing or default JWT_SECRET).
|
||||
config.MustInit()
|
||||
cfg := config.Get()
|
||||
logger.Info("✅ Configuration loaded")
|
||||
|
||||
@@ -121,10 +127,10 @@ func main() {
|
||||
status = "✅ Running"
|
||||
}
|
||||
idShort := t.ID
|
||||
if len(idShort) > 8 {
|
||||
idShort = idShort[:8]
|
||||
}
|
||||
logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s",
|
||||
if len(idShort) > 8 {
|
||||
idShort = idShort[:8]
|
||||
}
|
||||
logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s",
|
||||
t.Name, idShort, status, t.AIModelID, t.ExchangeID)
|
||||
}
|
||||
}
|
||||
@@ -132,28 +138,12 @@ func main() {
|
||||
// Start API server
|
||||
server := api.NewServer(traderManager, st, cryptoService, cfg.APIServerPort)
|
||||
|
||||
// Create hot-reload channel for Telegram bot; wire it to the API server
|
||||
// so that POST /api/telegram can trigger a bot restart when the token changes.
|
||||
telegramReloadCh := make(chan struct{}, 1)
|
||||
server.SetTelegramReloadCh(telegramReloadCh)
|
||||
|
||||
go func() {
|
||||
if err := server.Start(); err != nil {
|
||||
logger.Fatalf("❌ Failed to start API server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start the NOFXi web agent on top of the current dev branch services.
|
||||
nofxiAgent := nofxiagent.New(traderManager, st, nil, slog.Default())
|
||||
nofxiAgent.Start()
|
||||
defer nofxiAgent.Stop()
|
||||
|
||||
agentWeb := nofxiagent.NewWebHandler(nofxiAgent, slog.Default())
|
||||
server.RegisterAgentHandler(agentWeb)
|
||||
|
||||
// Start Telegram bot (if TELEGRAM_BOT_TOKEN is configured)
|
||||
go telegram.Start(cfg, st, telegramReloadCh)
|
||||
|
||||
// Wait for interrupt signal
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"nofx/store"
|
||||
"nofx/trader"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -410,6 +411,34 @@ func (tm *TraderManager) RemoveTrader(traderID string) {
|
||||
}
|
||||
}
|
||||
|
||||
func ensureHyperliquidNativeStrategy(traderName, exchangeType string, cfg *store.StrategyConfig) {
|
||||
if cfg == nil || strings.ToLower(strings.TrimSpace(exchangeType)) != "hyperliquid" {
|
||||
return
|
||||
}
|
||||
|
||||
source := strings.ToLower(strings.TrimSpace(cfg.CoinSource.SourceType))
|
||||
if source == "hyper_rank" || source == "vergex_signal" || source == "static" || source == "hyper_all" || source == "hyper_main" {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Warnf("⚠️ Trader %s uses legacy coin source %q on Hyperliquid; forcing native stock ranking to avoid crypto fallback", traderName, cfg.CoinSource.SourceType)
|
||||
cfg.CoinSource.SourceType = "hyper_rank"
|
||||
cfg.CoinSource.UseAI500 = false
|
||||
cfg.CoinSource.UseOITop = false
|
||||
cfg.CoinSource.UseOILow = false
|
||||
cfg.CoinSource.UseHyperAll = false
|
||||
cfg.CoinSource.UseHyperMain = false
|
||||
if cfg.CoinSource.HyperRankCategory == "" {
|
||||
cfg.CoinSource.HyperRankCategory = "stock"
|
||||
}
|
||||
if cfg.CoinSource.HyperRankDirection == "" {
|
||||
cfg.CoinSource.HyperRankDirection = "gainers"
|
||||
}
|
||||
if cfg.CoinSource.HyperRankLimit <= 0 {
|
||||
cfg.CoinSource.HyperRankLimit = 5
|
||||
}
|
||||
}
|
||||
|
||||
// LoadUserTradersFromStore loads traders from store for a specific user to memory
|
||||
func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string) error {
|
||||
tm.mu.Lock()
|
||||
@@ -616,25 +645,34 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
|
||||
// Load strategy config (must have strategy)
|
||||
var strategyConfig *store.StrategyConfig
|
||||
strategyConfigRaw := ""
|
||||
if traderCfg.StrategyID != "" {
|
||||
strategy, err := st.Strategy().Get(traderCfg.UserID, traderCfg.StrategyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load strategy %s for trader %s: %w", traderCfg.StrategyID, traderCfg.Name, err)
|
||||
}
|
||||
strategyConfigRaw = strategy.Config
|
||||
// Parse JSON config
|
||||
strategyConfig, err = strategy.ParseConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse strategy config for trader %s: %w", traderCfg.Name, err)
|
||||
}
|
||||
strategyConfig.ClampLimits()
|
||||
logger.Infof("✓ Trader %s loaded strategy config: %s", traderCfg.Name, strategy.Name)
|
||||
ensureHyperliquidNativeStrategy(traderCfg.Name, exchangeCfg.ExchangeType, strategyConfig)
|
||||
} else {
|
||||
return fmt.Errorf("trader %s has no strategy configured", traderCfg.Name)
|
||||
}
|
||||
|
||||
if exchangeCfg.ExchangeType == "hyperliquid" && !exchangeCfg.HyperliquidBuilderApproved {
|
||||
return fmt.Errorf("Hyperliquid trading authorization is incomplete for exchange %s; reconnect Hyperliquid wallet and complete trading authorization before starting trader %s", exchangeCfg.AccountName, traderCfg.Name)
|
||||
}
|
||||
|
||||
// Build AutoTraderConfig (ai500APIURL/oiTopAPIURL obtained from strategy config, used in StrategyEngine)
|
||||
traderConfig := trader.AutoTraderConfig{
|
||||
ID: traderCfg.ID,
|
||||
Name: traderCfg.Name,
|
||||
StrategyID: traderCfg.StrategyID,
|
||||
AIModel: aiModelCfg.Provider,
|
||||
Exchange: exchangeCfg.ExchangeType, // Exchange type: binance/bybit/okx/etc
|
||||
ExchangeID: exchangeCfg.ID, // Exchange account UUID (for multi-account)
|
||||
@@ -652,6 +690,7 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
IsCrossMargin: traderCfg.IsCrossMargin,
|
||||
ShowInCompetition: traderCfg.ShowInCompetition,
|
||||
StrategyConfig: strategyConfig,
|
||||
StrategyConfigRaw: strategyConfigRaw,
|
||||
}
|
||||
|
||||
logger.Infof("📊 Loading trader %s: ScanIntervalMinutes=%d (from DB), ScanInterval=%v",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user