mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-10 22:36:58 +08:00
Compare commits
159 Commits
release/me
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21407030ea | ||
|
|
7a66d048f3 | ||
|
|
8c8cd9b61f | ||
|
|
d84e22ab82 | ||
|
|
221cf3d61d | ||
|
|
390db72cdb | ||
|
|
c1b660bb45 | ||
|
|
c53a563bff | ||
|
|
e593874912 | ||
|
|
e9873b5b86 | ||
|
|
026c5d3089 | ||
|
|
ee5917adc6 | ||
|
|
9b70455e7f | ||
|
|
a744328313 | ||
|
|
b17d97effc | ||
|
|
e61f12d307 | ||
|
|
30f3feb586 | ||
|
|
49613830ce | ||
|
|
ea74f91de2 | ||
|
|
a6884998b3 | ||
|
|
277b5ad9c6 | ||
|
|
110bf52908 | ||
|
|
eba28bcf0e | ||
|
|
c4e79d9579 | ||
|
|
a4983d2cb0 | ||
|
|
24f6421a73 | ||
|
|
961e016d33 | ||
|
|
b95da3ed42 | ||
|
|
a47811f5ab | ||
|
|
2c6e2827e8 | ||
|
|
953240565f | ||
|
|
133ef51de8 | ||
|
|
bebe51bf89 | ||
|
|
3a048876bd | ||
|
|
4f3869c81c | ||
|
|
f3c33b55d7 | ||
|
|
785922697b | ||
|
|
332ddf61ef | ||
|
|
41c2625bb2 | ||
|
|
c0d8a9a375 | ||
|
|
9ea9bd705f | ||
|
|
094ab45476 | ||
|
|
220cb7428b | ||
|
|
1aea7abc38 | ||
|
|
577a0918c3 | ||
|
|
2d32a8f6c9 | ||
|
|
3c061aee94 | ||
|
|
30c6abca74 | ||
|
|
129952859e | ||
|
|
7f0a9f0749 | ||
|
|
b15c2da3a9 | ||
|
|
d008ccc6ab | ||
|
|
e4adafa364 | ||
|
|
1851508353 | ||
|
|
fcb73cc195 | ||
|
|
b9ae99da7e | ||
|
|
75832f9eb2 | ||
|
|
99361cb085 | ||
|
|
70db3f5ba3 | ||
|
|
f2eeea9659 | ||
|
|
eb73c8bdfa | ||
|
|
dea00b418c | ||
|
|
3b2e7027db | ||
|
|
f4ee723aa2 | ||
|
|
5bdffee3b0 | ||
|
|
c7c003cc3c | ||
|
|
f37fc9f887 | ||
|
|
908fc09aca | ||
|
|
ab5873e2de | ||
|
|
d80bb31c0a | ||
|
|
e2ccc6b911 | ||
|
|
bf289e8eb3 | ||
|
|
9f25bf49bf | ||
|
|
b8cde34e67 | ||
|
|
32e8a03a85 | ||
|
|
ca8bed4a58 | ||
|
|
94844b7139 | ||
|
|
e67a927a4f | ||
|
|
0f11be77f8 | ||
|
|
159f27dfdd | ||
|
|
25d0b30ea9 | ||
|
|
2d45e7ab15 | ||
|
|
fc6c42ac11 | ||
|
|
5ff7212cb3 | ||
|
|
3619f82796 | ||
|
|
03a307939e | ||
|
|
8d8a0cc72b | ||
|
|
fe0dbce367 | ||
|
|
b536265f93 | ||
|
|
30a703a827 | ||
|
|
d481b3d88c | ||
|
|
e8eafce1e0 | ||
|
|
ce3a8582af | ||
|
|
cfd91069d3 | ||
|
|
903eb591eb | ||
|
|
0d3b9536d5 | ||
|
|
132fd93072 | ||
|
|
9ee931ee30 | ||
|
|
c244e4cdf1 | ||
|
|
4cadf6f442 | ||
|
|
5dbe32d884 | ||
|
|
a20a71b88d | ||
|
|
3dbf5beece | ||
|
|
5d6ec35bb4 | ||
|
|
3ca95b294d | ||
|
|
c6d9ef469e | ||
|
|
1ba50bdedf | ||
|
|
737f9bca95 | ||
|
|
5c4e7502d7 | ||
|
|
7ae5bf8247 | ||
|
|
851f152c50 | ||
|
|
beb23c369f | ||
|
|
0a1a2923dc | ||
|
|
117d2f7fd4 | ||
|
|
802590c2b9 | ||
|
|
f5891aa39c | ||
|
|
a1f909adbe | ||
|
|
2f483633ed | ||
|
|
b9b0a52137 | ||
|
|
0d74c27be2 | ||
|
|
1464cedeff | ||
|
|
c2fc80e269 | ||
|
|
a3d8831b36 | ||
|
|
e1b5a5d833 | ||
|
|
c93ee337a7 | ||
|
|
eef78b7987 | ||
|
|
a1af4fec58 | ||
|
|
6fe849c18d | ||
|
|
4f0a922779 | ||
|
|
80272c0d5a | ||
|
|
8a0f3f5a13 | ||
|
|
0c1f438cc3 | ||
|
|
9a80f1d88d | ||
|
|
9937542020 | ||
|
|
287280857b | ||
|
|
d250aed26a | ||
|
|
608f02ed67 | ||
|
|
1d6e99c74a | ||
|
|
fb0bd13f51 | ||
|
|
55db747318 | ||
|
|
cab58afe6d | ||
|
|
9176aa9844 | ||
|
|
7464dfa892 | ||
|
|
2e2598e4e0 | ||
|
|
fbca4166a1 | ||
|
|
f83f2b1c18 | ||
|
|
c6adc34247 | ||
|
|
1d897f635e | ||
|
|
39782600a9 | ||
|
|
1c378007ee | ||
|
|
b0be49569c | ||
|
|
95e76f6a56 | ||
|
|
6cb6c31b34 | ||
|
|
b331733e23 | ||
|
|
4ab4024628 | ||
|
|
f0d3352971 | ||
|
|
af6f6d5930 | ||
|
|
2d68b48f52 | ||
|
|
9b14c5c84d |
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 }}
|
||||
|
||||
13
.gitignore
vendored
13
.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
|
||||
|
||||
@@ -126,3 +130,12 @@ dmypy.json
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
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
347
README.md
347
README.md
@@ -1,22 +1,14 @@
|
||||
<h1 align="center">NOFX</h1>
|
||||
<p align="center"><strong>Backed by <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Your personal AI trading assistant.</strong><br/>
|
||||
<strong>Any market. Any model. Pay with USDC, not API keys.</strong>
|
||||
<img src="docs/assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,270 +21,227 @@
|
||||
<a href="docs/i18n/vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
NOFX is an open-source **autonomous** AI trading assistant. Unlike traditional AI tools that require you to manually configure models, manage API keys, and wire up data sources — NOFX's AI **perceives markets, selects models, and fetches data entirely on its own**. Zero human intervention. You set the strategy, the AI handles everything else.
|
||||
NOFX is an open-source trading terminal where the strategy is a language model. Each trader runs a continuous loop — read market structure, decide, execute, record the reasoning — while a Go runtime clamps every order to hard risk limits the model cannot override.
|
||||
|
||||
**Fully autonomous**: The AI decides which model to use, what market data to pull, when to trade — all by itself. No manual model configuration. No juggling API keys for different services. Just fund a USDC wallet and let it run.
|
||||
|
||||
What makes it different: **built-in [x402](https://x402.org) micropayments**. No API keys. Fund a USDC wallet and pay per request. Your wallet is your identity.
|
||||
Traders compose freely: any model, any of nine exchanges, any strategy. Run several side by side and compare them on a public leaderboard by realized return. Everything runs on your own machine; exchange credentials are encrypted at rest and never leave it.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Open **http://127.0.0.1:3000**. Done.
|
||||
The terminal opens at `http://127.0.0.1:3000`.
|
||||
|
||||
---
|
||||
**First run**
|
||||
|
||||
## How x402 Works
|
||||
1. Register — the first account becomes the owner of the instance.
|
||||
2. Follow the guided launch: put **$1+ USDC** (Base network) in the AI fee wallet it creates for you, then connect Hyperliquid and deposit **$12+ USDC** to trade with.
|
||||
3. Start **Autopilot**. The AI scans the market every few minutes and trades on its own; every decision appears on the dashboard as it happens. Stop it anytime with one click.
|
||||
|
||||
Traditional flow: register account → buy credits → get API key → manage quota → rotate keys.
|
||||
<br/>
|
||||
|
||||
x402 flow:
|
||||
## Register exchanges
|
||||
|
||||
```
|
||||
Request → 402 (here's the price) → wallet signs USDC → retry → done
|
||||
```
|
||||
NOFX is free and open source. Opening an account through the partner links below carries reduced trading fees and funds continued development.
|
||||
|
||||
No accounts. No API keys. No prepaid credits. One wallet, every model.
|
||||
|
||||
### Built-in x402 Providers
|
||||
|
||||
| Provider | Chain | Models |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ models |
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
| Feature | Description |
|
||||
|:--------|:------------|
|
||||
| **Multi-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — switch anytime |
|
||||
| **Multi-Exchange** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Strategy Studio** | Visual builder — coin sources, indicators, risk controls |
|
||||
| **AI Competition** | AIs compete in real-time, leaderboard ranks performance |
|
||||
| **Telegram Agent** | Chat with your trading assistant — streaming, tool calling, memory |
|
||||
| **Dashboard** | Live positions, P/L, AI decision logs with Chain of Thought |
|
||||
|
||||
### Markets
|
||||
|
||||
Crypto · US Stocks · Forex · Metals
|
||||
|
||||
### Exchanges (CEX)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| Exchange | Status | Register with fee discount |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Register](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
|
||||
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Register](https://www.okx.com/join/1865360) |
|
||||
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Register](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Exchanges (Perp-DEX)
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Register](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI Models (API Key Mode)
|
||||
<br/>
|
||||
|
||||
| AI Model | Status | Get API Key |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Get API Key](https://platform.deepseek.com) |
|
||||
| <img src="web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Get API Key](https://dashscope.console.aliyun.com) |
|
||||
| <img src="web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Get API Key](https://platform.openai.com) |
|
||||
| <img src="web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Get API Key](https://console.anthropic.com) |
|
||||
| <img src="web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Get API Key](https://aistudio.google.com) |
|
||||
| <img src="web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Get API Key](https://console.x.ai) |
|
||||
| <img src="web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Get API Key](https://platform.moonshot.cn) |
|
||||
| <img src="web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Get API Key](https://platform.minimaxi.com) |
|
||||
## Demo
|
||||
|
||||
### AI Models (x402 Mode — No API Key)
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
15+ models via [Claw402](https://claw402.ai) — just a USDC wallet
|
||||
<br/>
|
||||
|
||||
---
|
||||
## The model proposes. The runtime disposes.
|
||||
|
||||
## Screenshots
|
||||
Decisions come from a language model reading the [Claw402.ai](https://claw402.ai) · Vergex data stack: a live signal board that ranks every market with directional bias and signal strength, per-symbol Signal Lab deep signals, cost-basis and liquidation heatmaps that show where the crowd's fuel and walls sit, and real-time market net flow — cross-checked against raw candles and the trader's own live track record. Execution does not.
|
||||
|
||||
Every order passes through limits enforced in code, outside the model's reach:
|
||||
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| Position limits | Max concurrent positions, notional capped as a ratio of equity, one position per symbol |
|
||||
| Leverage clamps | Hard caps applied at order-sizing time, independent of what the model requests |
|
||||
| Exchange-side protection | Stop-loss and take-profit placed on the exchange immediately after every entry |
|
||||
| Drawdown auto-close | Profitable positions that give back too much from their peak are closed |
|
||||
| Trade throttling | Minimum hold times, per-symbol re-entry cooldowns, per-cycle and per-hour entry limits |
|
||||
| Safe mode | Repeated model failures block new entries until the model recovers |
|
||||
| Launch preflight | Model access, wallet funds, strategy, and exchange balances verified before a trader may start |
|
||||
|
||||
Each decision is stored with the model's full reasoning. There is no position without a paper trail.
|
||||
|
||||
<br/>
|
||||
|
||||
## Terminal
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Autopilot** | Guided launch: fund, connect, deposit, start — with server-side preflight throughout |
|
||||
| **Strategy Studio** | Style presets, coin universes, indicators, leverage, entry confidence, custom prompts |
|
||||
| **Competition** | Public leaderboard ranked by realized return, each entry attributed to its model |
|
||||
| **Dashboard** | Live positions, orders, statistics, and the reasoning behind every decision |
|
||||
|
||||
<details>
|
||||
<summary><b>Config Page</b></summary>
|
||||
<summary>Screenshots</summary>
|
||||
|
||||
| AI Models & Exchanges | Traders List |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/config-ai-exchanges.png" width="400"/> | <img src="screenshots/config-traders-list.png" width="400"/> |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Dashboard</b></summary>
|
||||
<br/>
|
||||
|
||||
| Overview | Market Chart |
|
||||
|:---:|:---:|
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="screenshots/dashboard-page.png" width="400"/> | <img src="screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| Trading Stats | Position History |
|
||||
|:---:|:---:|
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="screenshots/dashboard-trading-stats.png" width="400"/> | <img src="screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| Positions | Trader Details |
|
||||
|:---:|:---:|
|
||||
| <img src="screenshots/dashboard-positions.png" width="400"/> | <img src="screenshots/details-page.png" width="400"/> |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Strategy Studio</b></summary>
|
||||
|
||||
| Strategy Editor | Indicators Config |
|
||||
|:---:|:---:|
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="screenshots/strategy-studio.png" width="400"/> | <img src="screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
| Competition | Configuration |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="screenshots/competition-page.png" width="400"/> | <img src="screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Competition</b></summary>
|
||||
<br/>
|
||||
|
||||
| Competition Mode |
|
||||
|:---:|
|
||||
| <img src="screenshots/competition-page.png" width="400"/> |
|
||||
</details>
|
||||
## Models
|
||||
|
||||
---
|
||||
Eight providers with your own keys — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — including custom endpoints and model names.
|
||||
|
||||
Or no keys at all: [Claw402](https://claw402.ai) meters model usage per call in USDC over the x402 protocol. A wallet on Base replaces every API key.
|
||||
|
||||
| Provider | Access |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [Pay-as-you-go AI models with official discount](https://claw402.ai) |
|
||||
|
||||
## Markets
|
||||
|
||||
Crypto perpetuals on all nine exchanges. On Hyperliquid, the same runtime also trades tokenized US equities, commodities, indices, FX, and pre-IPO perps — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — alongside crypto.
|
||||
|
||||
<br/>
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
<br/>
|
||||
|
||||
## Install
|
||||
|
||||
### Linux / macOS
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (Cloud)
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Install [Docker Desktop](https://www.docker.com/products/docker-desktop/), then:
|
||||
**Windows** — install [Docker Desktop](https://www.docker.com/products/docker-desktop/), then:
|
||||
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### From Source
|
||||
**From source** — Go 1.21+, Node.js 18+:
|
||||
|
||||
```bash
|
||||
# Prerequisites: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend (new terminal)
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
### Update
|
||||
**Update** — re-run the install script; it upgrades in place.
|
||||
|
||||
<details>
|
||||
<summary>Server deployment</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
---
|
||||
**HTTPS via Cloudflare**
|
||||
|
||||
## Setup
|
||||
|
||||
**Beginner mode**: First-time users get a guided onboarding flow — select beginner mode at registration and the system walks you through AI, exchange, and strategy setup step by step.
|
||||
|
||||
**Advanced mode**:
|
||||
|
||||
1. **AI** — Add API keys or configure x402 wallet
|
||||
2. **Exchange** — Connect exchange API credentials
|
||||
3. **Strategy** — Build in Strategy Studio
|
||||
4. **Trader** — Combine AI + Exchange + Strategy
|
||||
5. **Trade** — Launch from the dashboard
|
||||
|
||||
Everything through the web UI at **http://127.0.0.1:3000**.
|
||||
|
||||
---
|
||||
|
||||
## Deploy to Server
|
||||
|
||||
**HTTP (quick):**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# Access via http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**HTTPS (Cloudflare):**
|
||||
1. Add domain to [Cloudflare](https://dash.cloudflare.com) (free plan)
|
||||
2. A record → your server IP (Proxied)
|
||||
1. Add the domain to [Cloudflare](https://dash.cloudflare.com) (free plan)
|
||||
2. A record → server IP, proxied
|
||||
3. SSL/TLS → Flexible
|
||||
4. Set `TRANSPORT_ENCRYPTION=true` in `.env`
|
||||
4. `TRANSPORT_ENCRYPTION=true` in `.env`
|
||||
|
||||
---
|
||||
</details>
|
||||
|
||||
## Architecture
|
||||
<br/>
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Web Dashboard │
|
||||
│ React + TypeScript + TradingView │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
├──────────┬──────────┬──────────┬────────────────┤
|
||||
│ Strategy │ Telegram │
|
||||
│ Engine │ Agent │
|
||||
├──────────┴──────────┴──────────┴────────────────┤
|
||||
│ MCP AI Client Layer │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ API Key │ │ x402 │ │ │ │
|
||||
│ │ DeepSeek │ │ Claw402 │ │ │ │
|
||||
│ │ GPT,Claude │ │ │ │ │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectors │
|
||||
│ Binance · Bybit · OKX · Bitget · KuCoin · Gate │
|
||||
│ Hyperliquid · Aster DEX · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docs
|
||||
## Documentation
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [Getting Started](docs/getting-started/README.md) | Deployment and exchange API guides |
|
||||
| [Architecture](docs/architecture/README.md) | System design and module index |
|
||||
| [Strategy Module](docs/architecture/STRATEGY_MODULE.md) | Coin selection, AI prompts, execution |
|
||||
| [FAQ](docs/faq/README.md) | Common questions |
|
||||
| [Getting Started](docs/getting-started/README.md) | Deployment guide |
|
||||
| [FAQ](docs/guides/faq.en.md) | Common questions |
|
||||
| [Troubleshooting](docs/guides/TROUBLESHOOTING.md) | Diagnosing common issues |
|
||||
|
||||
---
|
||||
## Community
|
||||
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [Live dashboard](https://vergex.trade/explore)
|
||||
|
||||
## Contributing
|
||||
|
||||
See [Contributing Guide](CONTRIBUTING.md) · [Code of Conduct](CODE_OF_CONDUCT.md) · [Security Policy](SECURITY.md)
|
||||
Code, documentation, translations, and bug reports are all welcome — see the [Contributing Guide](CONTRIBUTING.md), [Code of Conduct](CODE_OF_CONDUCT.md), and [Security Policy](SECURITY.md).
|
||||
|
||||
### Contributor Airdrop Program
|
||||
|
||||
All contributions are tracked. When NOFX generates revenue, contributors receive airdrops.
|
||||
|
||||
**[Pinned Issues](https://github.com/NoFxAiOS/nofx/issues) get the highest rewards.**
|
||||
NOFX tracks meaningful contributions and intends to reward contributors as the ecosystem grows. Priority issues carry higher weight.
|
||||
|
||||
| Contribution | Weight |
|
||||
|:-------------|:------:|
|
||||
| :---------------- | :----: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
@@ -300,21 +249,9 @@ All contributions are tracked. When NOFX generates revenue, contributors receive
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| Website | [nofxai.com](https://nofxai.com) |
|
||||
| Dashboard | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API Docs | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
|
||||
> **Risk Warning**: AI auto-trading carries significant risks. Recommended for learning/research or small amounts only.
|
||||
|
||||
---
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## Sponsors
|
||||
|
||||
@@ -332,8 +269,14 @@ All contributions are tracked. When NOFX generates revenue, contributors receive
|
||||
|
||||
[Become a sponsor](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
<br/>
|
||||
|
||||
If NOFX is useful to you, a star helps other traders find it.
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>Automated trading involves substantial risk. AI-driven strategies are experimental and can lose money. Size positions appropriately, understand each venue, and never trade funds you cannot afford to lose. Full [disclaimer](DISCLAIMER.md).</sub>
|
||||
|
||||
922
agents.md
Normal file
922
agents.md
Normal file
@@ -0,0 +1,922 @@
|
||||
# NOFXi 交易智能助手规范
|
||||
|
||||
## 使命
|
||||
|
||||
NOFXi 交易智能助手不是通用闲聊机器人,而是一个面向交易场景的操作与决策辅助助手。
|
||||
|
||||
它的核心目标是帮助用户更安全、更高效、更专业地完成以下事情:
|
||||
|
||||
- 创建、启动、查询、编辑、删除 agent
|
||||
- 管理交易所配置
|
||||
- 管理策略
|
||||
- 管理大模型配置
|
||||
- 排查配置问题与运行问题
|
||||
- 回答交易相关问题,并提供可执行的建议
|
||||
|
||||
助手的价值不在于“会聊天”,而在于:
|
||||
|
||||
- 降低用户操作成本
|
||||
- 减少配置错误和误操作
|
||||
- 提高问题定位效率
|
||||
- 让交易过程更专业、更可靠
|
||||
|
||||
## 核心理念
|
||||
|
||||
本助手采用 `80% skill + 20% 动态规划` 的设计思路。
|
||||
|
||||
这意味着:
|
||||
|
||||
- 大多数高频、已知、可标准化的需求,应由预定义 skill 处理
|
||||
- 不应让模型对已知流程重复思考
|
||||
- 动态规划只用于少数复杂、跨领域、未知或开放性任务
|
||||
- 能确定的事情就不要交给模型自由发挥
|
||||
|
||||
默认优先级如下:
|
||||
|
||||
1. 优先匹配 skill
|
||||
2. 如果用户仍在当前任务中,则继续当前 skill
|
||||
3. 只有当没有合适 skill 时,才进入动态规划
|
||||
|
||||
## 设计原则
|
||||
|
||||
### 1. 以 Skill 为主,不以自由推理为主
|
||||
|
||||
对于高频任务和高风险任务,必须优先使用 skill,而不是通用 agent 自行规划。
|
||||
|
||||
尤其是以下场景:
|
||||
|
||||
- 创建 agent
|
||||
- 启动或停止 agent
|
||||
- 新增或修改交易所配置
|
||||
- 新增或修改策略
|
||||
- 新增或修改模型配置
|
||||
- 常见报错排查
|
||||
- API 配置指导
|
||||
|
||||
这些任务都应有稳定、明确、可重复执行的处理路径。
|
||||
|
||||
### 2. 以用户任务为中心,不以内部对象或 API 为中心
|
||||
|
||||
skill 的拆分应该围绕“用户想完成什么任务”,而不是“系统里有哪些对象”或“有哪些接口”。
|
||||
|
||||
好的拆分方式:
|
||||
|
||||
- 创建一个 agent
|
||||
- 启动或停止一个 agent
|
||||
- 排查交易所 API 连接失败
|
||||
- 指导用户配置某个模型的 API
|
||||
- 解释某条报错并给出下一步
|
||||
|
||||
不好的拆分方式:
|
||||
|
||||
- exchange skill
|
||||
- strategy 对象 skill
|
||||
- 通用 REST 调用 skill
|
||||
- 纯接口包装型 skill
|
||||
|
||||
用户关注的是任务结果,不是内部实现。
|
||||
|
||||
### 3. 多轮对话的目标是推进任务,不是维持聊天感
|
||||
|
||||
多轮对话的本质,不是“让助手显得更像人”,而是让任务从模糊走向完成。
|
||||
|
||||
每一轮都应围绕以下问题展开:
|
||||
|
||||
- 当前正在处理什么任务
|
||||
- 当前任务已经确认了哪些信息
|
||||
- 还缺什么关键信息
|
||||
- 下一步最合理的推进动作是什么
|
||||
|
||||
### 4. 只追问必要信息
|
||||
|
||||
当任务可以继续推进时,不要提出宽泛、发散、无助于执行的问题。
|
||||
|
||||
助手只应追问:
|
||||
|
||||
- 当前任务必需但缺失的字段
|
||||
- 影响结果的重要选择项
|
||||
- 涉及风险、删除、替换、启动、停止等动作时的确认信息
|
||||
|
||||
不要要求用户重复已经确认过的信息。
|
||||
|
||||
### 5. 尽量减少不必要的思考
|
||||
|
||||
对于已有稳定处理路径的任务,直接按既定流程执行,不进行自由规划。
|
||||
|
||||
不要把模型能力浪费在这些事情上:
|
||||
|
||||
- 猜测标准流程
|
||||
- 重新设计高频任务执行顺序
|
||||
- 对常见配置问题进行开放式发散分析
|
||||
- 对结构化任务做不必要的“创造性理解”
|
||||
|
||||
### 6. 高风险动作优先保证安全
|
||||
|
||||
任何可能造成损失、误操作、难以回滚或影响实盘的动作,都必须谨慎处理。
|
||||
|
||||
以下动作通常需要明确确认:
|
||||
|
||||
- 删除 agent
|
||||
- 删除交易所配置
|
||||
- 删除策略
|
||||
- 覆盖已有配置
|
||||
- 启动实盘 agent
|
||||
- 停止正在运行的 agent
|
||||
- 修改可能影响下单行为的关键参数
|
||||
|
||||
当用户意图不够明确时,宁可先确认,不要直接执行。
|
||||
|
||||
### 7. 回答要以可执行为目标
|
||||
|
||||
当用户提问、排障、求指导时,回答应优先提供清晰的下一步,而不是停留在抽象概念。
|
||||
|
||||
尽量围绕这三个问题组织回答:
|
||||
|
||||
- 发生了什么
|
||||
- 为什么会这样
|
||||
- 现在该怎么做
|
||||
|
||||
## 任务分类
|
||||
|
||||
### 一、执行类任务
|
||||
|
||||
执行类任务是指目标明确、结果清晰、可以落到具体系统动作上的任务。
|
||||
|
||||
例如:
|
||||
|
||||
- 创建 agent
|
||||
- 编辑 agent
|
||||
- 启动 agent
|
||||
- 停止 agent
|
||||
- 删除 agent
|
||||
- 创建交易所配置
|
||||
- 修改交易所配置
|
||||
- 删除交易所配置
|
||||
- 创建策略
|
||||
- 编辑策略
|
||||
- 激活策略
|
||||
- 复制策略
|
||||
- 删除策略
|
||||
- 创建模型配置
|
||||
- 修改模型配置
|
||||
- 删除模型配置
|
||||
|
||||
这类任务应优先通过 skill 实现,避免自由规划。
|
||||
|
||||
### 二、诊断类任务
|
||||
|
||||
诊断类任务是指用户遇到了问题,需要助手帮助识别原因、缩小范围、给出修复步骤。
|
||||
|
||||
例如:
|
||||
|
||||
- 某条报错是什么意思
|
||||
- 为什么模型 API 配置失败
|
||||
- 为什么交易所 API 连接不上
|
||||
- 为什么 agent 启动失败
|
||||
- 为什么策略没有执行
|
||||
- 为什么余额、仓位、收益统计不对
|
||||
- 为什么某个配置在前端能保存,但运行时报错
|
||||
|
||||
这类任务也应尽量 skill 化,形成稳定的排查路径,而不是每次从零分析。
|
||||
|
||||
### 三、指导类任务
|
||||
|
||||
指导类任务是指用户需要完成某项配置、接入、理解或选择,但不一定立刻触发系统动作。
|
||||
|
||||
例如:
|
||||
|
||||
- 某个模型的 API key 去哪里申请
|
||||
- 某个模型的 base URL 和 model name 怎么填
|
||||
- 某个交易所 API key 怎么创建
|
||||
- 某个交易所权限应该怎么勾选
|
||||
- 某种策略适合什么市场环境
|
||||
- 某些交易指标怎么理解
|
||||
|
||||
这类任务应提供步骤化、实操型指导。
|
||||
|
||||
### 四、动态规划类任务
|
||||
|
||||
动态规划不是默认模式,而是兜底模式。
|
||||
|
||||
只有在以下情况下,才允许进入动态规划:
|
||||
|
||||
- 用户请求跨越多个 skill
|
||||
- 用户描述模糊,需要先探索再判断
|
||||
- 用户提出的是开放式交易问题
|
||||
- 用户的问题不属于已有 skill 覆盖范围
|
||||
- 需要组合查询、分析、判断和建议
|
||||
|
||||
动态规划可以存在,但必须受控,不能覆盖主路径。
|
||||
|
||||
## 多轮对话策略
|
||||
|
||||
### 一、优先延续当前任务
|
||||
|
||||
如果用户仍然在处理同一个任务,就继续当前任务,不要重新规划或重新路由。
|
||||
|
||||
例如:
|
||||
|
||||
- 用户:帮我创建一个新的 BTC agent
|
||||
- 助手:请提供交易所和模型配置
|
||||
- 用户:用我刚配的 DeepSeek
|
||||
|
||||
这时应继续“创建 agent”这个任务,而不是重新理解成一个新的需求。
|
||||
|
||||
### 二、多轮对话以任务状态推进为核心
|
||||
|
||||
每个任务在多轮中都应该有明确状态,例如:
|
||||
|
||||
- 已识别任务
|
||||
- 信息收集中
|
||||
- 等待用户确认
|
||||
- 执行中
|
||||
- 已完成
|
||||
- 执行失败,待修复
|
||||
- 已中断或已切换
|
||||
|
||||
助手应始终知道当前任务在哪个阶段,而不是每轮都从头开始解释世界。
|
||||
|
||||
### 三、只补齐缺失参数,不重复收集已有信息
|
||||
|
||||
如果一个 skill 已经定义了所需字段,那么多轮中的追问应只围绕缺失字段展开。
|
||||
|
||||
例如创建 agent 时,可能需要:
|
||||
|
||||
- 名称
|
||||
- 交易所
|
||||
- 策略
|
||||
- 模型
|
||||
- 是否立即启动
|
||||
|
||||
如果其中三个字段已经确认,就不要重新追问这三个字段。
|
||||
|
||||
### 四、允许用户中途切换任务
|
||||
|
||||
如果用户明显改变了目标,助手应允许当前任务中断,并切换到新任务。
|
||||
|
||||
例如:
|
||||
|
||||
- 当前任务:创建 agent
|
||||
- 用户突然说:为什么我的交易所 API 报 invalid signature
|
||||
|
||||
这时应切换到诊断类任务,而不是强行把用户拉回创建流程。
|
||||
|
||||
### 五、允许短暂插问,但尽量回到主任务
|
||||
|
||||
如果用户在当前任务中插入一个简短问题,助手可以先简要回答,再视情况回到主任务。
|
||||
|
||||
例如:
|
||||
|
||||
- 用户正在创建策略
|
||||
- 中途问:逐仓和全仓有什么区别
|
||||
|
||||
助手可以先给简洁解释,再继续原任务。
|
||||
|
||||
### 六、对高风险动作单独确认
|
||||
|
||||
即使任务流程已经基本完成,只要最后一步属于高风险动作,也要在执行前单独确认。
|
||||
|
||||
例如:
|
||||
|
||||
- 删除策略前确认
|
||||
- 启动实盘前确认
|
||||
- 覆盖已有配置前确认
|
||||
|
||||
## 记忆策略
|
||||
|
||||
### 一、记住对当前任务有用的信息
|
||||
|
||||
当前会话中,应保留以下内容:
|
||||
|
||||
- 当前活跃任务
|
||||
- 已确认的参数
|
||||
- 用户明确表达过的选择
|
||||
- 仍然缺失的关键字段
|
||||
- 当前排障上下文
|
||||
- 最近一次确认结果
|
||||
|
||||
### 二、不把猜测当成记忆
|
||||
|
||||
以下内容不应被高强度依赖:
|
||||
|
||||
- 助手自行推断但用户未确认的偏好
|
||||
- 早前对话中的过时信息
|
||||
- 与当前任务无关的旧上下文
|
||||
- 仅基于模糊表达做出的假设
|
||||
|
||||
如果有不确定性,应明确标注为“推测”或重新确认。
|
||||
|
||||
### 三、敏感信息只在必要范围内使用
|
||||
|
||||
对于 API key、密钥、凭证、账户等敏感信息:
|
||||
|
||||
- 不要在回答中完整复述
|
||||
- 不要在无关任务中再次提起
|
||||
- 仅在当前任务确有需要时使用
|
||||
- 默认进行脱敏展示
|
||||
|
||||
## Skill 设计规范
|
||||
|
||||
每个 skill 都应服务于一个真实、完整、可交付的用户任务。
|
||||
|
||||
一个好的 skill 应当具备以下特点:
|
||||
|
||||
- 范围足够聚焦,执行稳定
|
||||
- 范围又不能过小,能够完成完整任务
|
||||
- 输入要求清晰
|
||||
- 流程尽量确定
|
||||
- 成功和失败条件明确
|
||||
- 容易扩展和维护
|
||||
|
||||
每个 skill 至少应定义以下内容:
|
||||
|
||||
- 处理的意图
|
||||
- 适用场景
|
||||
- 必填输入
|
||||
- 可选输入
|
||||
- 前置条件
|
||||
- 执行步骤
|
||||
- 缺少信息时如何追问
|
||||
- 哪些步骤需要确认
|
||||
- 成功后的输出格式
|
||||
- 常见失败情况
|
||||
- 对应的恢复建议
|
||||
|
||||
## 工具使用原则
|
||||
|
||||
工具只是 skill 或动态规划中的执行手段,不应成为助手行为设计的核心。
|
||||
|
||||
助手不应表现为:
|
||||
|
||||
- 一个通用 API 调用器
|
||||
- 一个只会函数路由的壳
|
||||
- 一个对常规任务也反复规划的自治代理
|
||||
|
||||
默认顺序应为:
|
||||
|
||||
1. 先判断是否有合适 skill
|
||||
2. 在 skill 内部调用所需工具
|
||||
3. 如果没有 skill,再进入受限动态规划
|
||||
4. 最后才考虑通用探索式工具调用
|
||||
|
||||
## Skill 与 Tool 的分层原则
|
||||
|
||||
Skill 和 tool 不是同一层概念。
|
||||
|
||||
tool 是底层执行能力,skill 是面向用户任务的稳定流程。
|
||||
|
||||
默认架构应为:
|
||||
|
||||
用户请求 -> 匹配 skill -> skill 内部调用 tool -> 返回结果
|
||||
|
||||
而不是:
|
||||
|
||||
用户请求 -> 大模型直接在一堆底层 tool 中自由选择和规划
|
||||
|
||||
### 一、Skill 是面向任务的
|
||||
|
||||
skill 应围绕用户目标设计,例如:
|
||||
|
||||
- 创建 agent
|
||||
- 启动或停止 agent
|
||||
- 配置交易所 API
|
||||
- 诊断模型配置失败
|
||||
- 解释某类报错
|
||||
|
||||
skill 负责定义:
|
||||
|
||||
- 要处理什么任务
|
||||
- 需要哪些输入
|
||||
- 缺信息时怎么追问
|
||||
- 执行顺序是什么
|
||||
- 哪些动作需要确认
|
||||
- 失败时怎么恢复
|
||||
|
||||
### 二、Tool 是面向执行的
|
||||
|
||||
tool 负责具体动作,不负责完整任务语义。
|
||||
|
||||
例如:
|
||||
|
||||
- 读取当前模型配置
|
||||
- 保存交易所配置
|
||||
- 查询 trader 列表
|
||||
- 启动某个 trader
|
||||
- 获取余额
|
||||
- 获取持仓
|
||||
|
||||
tool 更像“系统能力”或“执行接口”,而不是用户直接感知的工作单元。
|
||||
|
||||
### 三、优先把底层 tool 收敛到 skill 内部
|
||||
|
||||
在 skill-first 架构下,不应默认把大量底层 tool 直接暴露给大模型。
|
||||
|
||||
更合理的做法是:
|
||||
|
||||
- 大模型优先决定使用哪个 skill
|
||||
- skill 内部自己决定需要调用哪些 tool
|
||||
- 用户不需要面对底层能力拆分
|
||||
- 模型也不需要在每次请求中重新拼装流程
|
||||
|
||||
### 四、可以直接暴露给大模型的,应当是高层 skill 化能力
|
||||
|
||||
如果某些能力需要以 function/tool 的形式提供给大模型,也应尽量保持高层抽象,而不是过度原子化。
|
||||
|
||||
较好的直接暴露方式:
|
||||
|
||||
- `manage_trader`
|
||||
- `manage_exchange_config`
|
||||
- `manage_model_config`
|
||||
- `manage_strategy`
|
||||
- `diagnose_trader_start_failure`
|
||||
|
||||
较差的直接暴露方式:
|
||||
|
||||
- `get_model_list_then_find_enabled_one`
|
||||
- `read_exchange_then_patch_field`
|
||||
- `generic_api_request`
|
||||
- 纯粹的 CRUD 原子碎片接口
|
||||
|
||||
也就是说,即使最终在技术实现上仍然使用 tool calling,这些 tool 也应该尽量表现为 skill,而不是裸露的底层零件。
|
||||
|
||||
### 五、只有在以下情况,才允许直接使用底层 tool
|
||||
|
||||
- 当前请求没有匹配 skill
|
||||
- 请求属于探索式、一次性、低频问题
|
||||
- 需要动态组合多个能力处理未知问题
|
||||
- 当前是在做诊断型探索,而不是执行标准流程
|
||||
|
||||
即使如此,也应优先限制范围,避免进入无边界的自由调用。
|
||||
|
||||
### 六、设计目标
|
||||
|
||||
引入 skill 的目的,不是让系统层次变复杂,而是让大模型少思考那些不需要思考的事情。
|
||||
|
||||
因此分层目标应是:
|
||||
|
||||
- 高频任务由 skill 固化
|
||||
- 低层动作沉到 skill 内部
|
||||
- 大模型少接触原子化 tool
|
||||
- 只有少数未知问题才进入动态规划
|
||||
|
||||
## 交易场景下的行为要求
|
||||
|
||||
交易助手必须让整体体验显得专业、谨慎、清晰。
|
||||
|
||||
这意味着:
|
||||
|
||||
- 操作建议要结构化
|
||||
- 配置指导要准确
|
||||
- 风险提示要明确
|
||||
- 不确定性要说清楚
|
||||
- 不应伪装成对市场有绝对把握
|
||||
|
||||
当涉及交易建议时,应尽量区分:
|
||||
|
||||
- 客观事实
|
||||
- 助手判断
|
||||
- 用户可执行的下一步
|
||||
|
||||
对于行情和策略分析,应优先给出条件化建议,而不是绝对判断。
|
||||
|
||||
例如应更倾向于:
|
||||
|
||||
- 如果你是震荡思路,可以考虑……
|
||||
- 如果当前目标是降低回撤,优先检查……
|
||||
- 这个现象更像是配置问题,不一定是策略本身失效
|
||||
|
||||
而不是:
|
||||
|
||||
- 这个市场一定会涨
|
||||
- 你应该马上开多
|
||||
- 这个策略就是最优解
|
||||
|
||||
## 默认处理流程
|
||||
|
||||
当用户发来请求时,助手默认按以下顺序处理:
|
||||
|
||||
1. 先判断这是不是一个已知高频任务
|
||||
2. 如果是,直接进入对应 skill
|
||||
3. 如果任务信息不完整,只追问继续执行所需的最少字段
|
||||
4. 如果属于诊断问题,先判断问题类型,再进入对应排查路径
|
||||
5. 如果属于开放式问题或跨 skill 问题,才进入动态规划
|
||||
6. 如果涉及高风险动作,在执行前单独确认
|
||||
7. 完成后给出简洁、明确、可执行的结果反馈
|
||||
|
||||
## 总结原则
|
||||
|
||||
本助手的核心不是“尽可能多地思考”,而是“在正确的地方思考”。
|
||||
|
||||
应当 skill 化的事情,就不要交给模型自由发挥。
|
||||
应当标准化的流程,就不要每次重新规划。
|
||||
应当确认的风险动作,就不要直接执行。
|
||||
|
||||
多轮对话的价值,在于持续推进任务、减少用户负担、提升交易操作质量。
|
||||
|
||||
## 当前落地状态
|
||||
|
||||
第一批诊断与配置类 skill 已开始沉淀,见:
|
||||
|
||||
- `docs/agent-skills/diagnostic-skills.zh-CN.md`
|
||||
|
||||
当前实现优先覆盖:
|
||||
|
||||
- 模型 API 配置与诊断
|
||||
- 交易所 API 配置与诊断
|
||||
- trader 启动与运行诊断
|
||||
- 下单与仓位异常诊断
|
||||
- 策略与 prompt 生效问题诊断
|
||||
|
||||
## 当前能力分层建议
|
||||
|
||||
下面这部分用于指导后续 agent 重构:哪些现有能力适合继续保留给大模型,哪些应该下沉到 skill 内部,哪些应该弱化或移除。
|
||||
|
||||
### 一、建议保留为高层 skill 的能力
|
||||
|
||||
这些能力已经接近“用户任务”粒度,适合继续保留为高层入口。
|
||||
|
||||
- `manage_trader`
|
||||
- `manage_exchange_config`
|
||||
- `manage_model_config`
|
||||
- `manage_strategy`
|
||||
- `execute_trade`
|
||||
- `get_positions`
|
||||
- `get_balance`
|
||||
- `get_trade_history`
|
||||
- `search_stock`
|
||||
|
||||
原因:
|
||||
|
||||
- 用户会直接表达这类任务
|
||||
- 这些能力已经具备较完整的业务语义
|
||||
- 它们天然适合作为 skill 或 skill-like tool
|
||||
|
||||
后续建议:
|
||||
|
||||
- 保持这些能力对外稳定
|
||||
- 在其上继续补充确认规则、缺参追问规则和诊断分支
|
||||
|
||||
### 二、建议下沉到 skill 内部的能力
|
||||
|
||||
这些能力可以继续存在,但不应作为主要交互层暴露给大模型自由组合。
|
||||
|
||||
- 读取某个资源后再 patch 某个字段
|
||||
- 各类配置查询后再拼装参数
|
||||
- 针对单一字段的修改动作
|
||||
- 仅为执行中间步骤服务的查询动作
|
||||
- 各种“先查一下列表再让模型自己猜怎么用”的细碎能力
|
||||
|
||||
原因:
|
||||
|
||||
- 这类能力更像流程零件
|
||||
- 一旦直接暴露给大模型,会导致每次都重新规划
|
||||
- 会让高频任务变得不稳定且冗长
|
||||
|
||||
原则上,这些动作应由 skill 内部封装完成,而不是让模型临场拼接。
|
||||
|
||||
### 三、建议弱化的能力形态
|
||||
|
||||
以下设计方向应尽量弱化:
|
||||
|
||||
- 通用 `generic_api_request`
|
||||
- 纯 CRUD 原子接口直接暴露给大模型
|
||||
- 没有任务语义的“万能工具”
|
||||
- 需要模型自己理解完整调用顺序的碎片化接口
|
||||
|
||||
原因:
|
||||
|
||||
- 这类能力过于底层
|
||||
- 会把流程控制权交还给模型
|
||||
- 与“80%% skill + 20%% 动态规划”的目标相冲突
|
||||
|
||||
### 四、建议新增的高层 skill 结构
|
||||
|
||||
后续不建议把高频管理操作拆成大量 `skill_create_xxx / skill_update_xxx` 形式。
|
||||
|
||||
更合理的方式是按“资源管理域”收敛为少量 management skill:
|
||||
|
||||
- `trader_management`
|
||||
- `exchange_management`
|
||||
- `model_management`
|
||||
- `strategy_management`
|
||||
|
||||
这些 management skill 可以在内部继续复用现有:
|
||||
|
||||
- `manage_trader`
|
||||
- `manage_exchange_config`
|
||||
- `manage_model_config`
|
||||
- `manage_strategy`
|
||||
|
||||
也就是说,现有高层管理工具可以作为 management skill 的执行底座,但不应继续承担全部对话策略。
|
||||
|
||||
#### management skill 的统一协议
|
||||
|
||||
每个 management skill 都应至少定义:
|
||||
|
||||
- `action`
|
||||
- `target_ref`
|
||||
- `slots`
|
||||
- `needs_confirmation`
|
||||
|
||||
推荐结构如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"skill": "exchange_management",
|
||||
"action": "update",
|
||||
"target_ref": {
|
||||
"id": "optional",
|
||||
"name": "主账户",
|
||||
"alias": "optional"
|
||||
},
|
||||
"slots": {
|
||||
"passphrase": "xxx"
|
||||
},
|
||||
"needs_confirmation": false
|
||||
}
|
||||
```
|
||||
|
||||
#### action 规则
|
||||
|
||||
不同 management skill 的 action 应集中定义,而不是散落在 prompt 中。
|
||||
|
||||
- `trader_management`
|
||||
- `create`
|
||||
- `update`
|
||||
- `delete`
|
||||
- `start`
|
||||
- `stop`
|
||||
- `query`
|
||||
- `exchange_management`
|
||||
- `create`
|
||||
- `update`
|
||||
- `delete`
|
||||
- `query`
|
||||
- `model_management`
|
||||
- `create`
|
||||
- `update`
|
||||
- `delete`
|
||||
- `query`
|
||||
- `strategy_management`
|
||||
- `create`
|
||||
- `update`
|
||||
- `delete`
|
||||
- `activate`
|
||||
- `duplicate`
|
||||
- `query`
|
||||
|
||||
#### reference 规则
|
||||
|
||||
management skill 不应要求用户总是提供精确 id,而应支持分层定位目标:
|
||||
|
||||
1. 优先使用 `id`
|
||||
2. 其次使用 `name`
|
||||
3. 再其次使用 alias / 最近上下文引用
|
||||
4. 若命中多个对象,则要求用户明确选择
|
||||
5. 若未命中任何对象,则返回“未找到目标对象”,而不是猜测执行
|
||||
|
||||
#### slot 规则
|
||||
|
||||
每个 action 都应定义:
|
||||
|
||||
- 必填 slots
|
||||
- 可选 slots
|
||||
- 自动推断规则
|
||||
- 缺失字段时的最小追问规则
|
||||
|
||||
例如:
|
||||
|
||||
- `exchange_management.create`
|
||||
- 必填:`exchange_type`
|
||||
- 常见必填:`account_name`、凭证字段
|
||||
- `exchange_management.update`
|
||||
- 必填:`target_ref`
|
||||
- 其余只需要用户明确要改的字段
|
||||
- `trader_management.create`
|
||||
- 必填:`name`、`exchange`、`model`
|
||||
- 常见可选:`strategy`、`auto_start`
|
||||
|
||||
#### confirmation 规则
|
||||
|
||||
management skill 内部必须按 action 级别区分风险,而不是统一处理。
|
||||
|
||||
- `delete` 默认必须确认
|
||||
- `start` / `stop` 视场景确认
|
||||
- `create` 通常可直接执行
|
||||
- `update` 若涉及关键配置变更,可要求确认
|
||||
- `query` 不需要确认
|
||||
|
||||
### 五、建议新增的诊断类 skill
|
||||
|
||||
诊断类 skill 是交易助手体验差异化的关键。
|
||||
|
||||
建议优先固定以下能力:
|
||||
|
||||
- `model_diagnosis`
|
||||
- `exchange_diagnosis`
|
||||
- `trader_diagnosis`
|
||||
- `order_execution_diagnosis`
|
||||
- `strategy_diagnosis`
|
||||
- `balance_position_diagnosis`
|
||||
|
||||
这些 skill 应优先基于:
|
||||
|
||||
- 已有代码中的真实约束
|
||||
- 现有 troubleshooting 文档
|
||||
- 真实常见错误文案
|
||||
- 当前系统的实际运行逻辑
|
||||
|
||||
### 六、建议保留给动态规划的少数场景
|
||||
|
||||
以下场景仍然可以保留给 planner / ReAct:
|
||||
|
||||
- 跨多个 skill 的复合任务
|
||||
- 用户目标表述模糊,需要先澄清再决定流程
|
||||
- 开放式交易问题
|
||||
- 一次性、低频、尚未固化的问题
|
||||
- 涉及诊断探索但还没有稳定 skill 的场景
|
||||
|
||||
动态规划应始终作为兜底层,而不是主路径。
|
||||
|
||||
### 七、最终目标分层
|
||||
|
||||
理想结构如下:
|
||||
|
||||
1. 用户表达需求
|
||||
2. 系统先判断是否命中高频 skill
|
||||
3. 若命中,则进入对应 skill 流程
|
||||
4. skill 内部调用现有管理类能力或查询能力
|
||||
5. 只有未命中 skill 时,才进入 planner
|
||||
|
||||
长期目标不是“让 planner 更聪明”,而是“让 planner 更少出场”。
|
||||
|
||||
## `agent/tools.go` 重构清单
|
||||
|
||||
当前 `agent/tools.go` 中主要暴露了以下工具:
|
||||
|
||||
- `get_preferences`
|
||||
- `manage_preferences`
|
||||
- `get_exchange_configs`
|
||||
- `manage_exchange_config`
|
||||
- `get_model_configs`
|
||||
- `manage_model_config`
|
||||
- `get_strategies`
|
||||
- `manage_strategy`
|
||||
- `manage_trader`
|
||||
- `search_stock`
|
||||
- `execute_trade`
|
||||
- `get_positions`
|
||||
- `get_balance`
|
||||
- `get_market_price`
|
||||
- `get_trade_history`
|
||||
|
||||
下面给出按当前设计目标的建议分类。
|
||||
|
||||
### 一、建议继续保留为高层入口的工具
|
||||
|
||||
这些工具已经具备较完整的任务语义,短期内可以继续作为高层 skill-like tool 保留。
|
||||
|
||||
- `manage_exchange_config`
|
||||
- `manage_model_config`
|
||||
- `manage_strategy`
|
||||
- `manage_trader`
|
||||
- `execute_trade`
|
||||
|
||||
原因:
|
||||
|
||||
- 它们都对应明确的用户任务
|
||||
- 内部已经承载了一定业务语义
|
||||
- 后续可以直接继续向 skill 演进,而不是推倒重来
|
||||
|
||||
重构建议:
|
||||
|
||||
- 保持接口稳定
|
||||
- 在 planner / prompt 层优先把它们当作 management skill 的执行底座使用
|
||||
- 后续逐步把对话语义前移到 `xxx_management`
|
||||
|
||||
### 二、建议保留为“只读能力”但弱化对外存在感的工具
|
||||
|
||||
这些工具适合继续保留,但主要作为查询型能力存在,不应成为复杂任务的主流程控制中心。
|
||||
|
||||
- `get_exchange_configs`
|
||||
- `get_model_configs`
|
||||
- `get_strategies`
|
||||
- `get_positions`
|
||||
- `get_balance`
|
||||
- `get_market_price`
|
||||
- `get_trade_history`
|
||||
- `search_stock`
|
||||
|
||||
原因:
|
||||
|
||||
- 它们更适合做信息补充和状态验证
|
||||
- 对诊断问题很有价值
|
||||
- 但不应该替代 task-level skill
|
||||
|
||||
重构建议:
|
||||
|
||||
- 继续保留
|
||||
- 主要用于:
|
||||
- skill 内部验证
|
||||
- 诊断类 skill 查询当前状态
|
||||
- 明确的只读用户请求
|
||||
- 不要鼓励模型把它们当成“拼工作流”的基础零件反复组合
|
||||
|
||||
### 三、建议进一步收敛使用边界的工具
|
||||
|
||||
以下工具容易把模型带回到底层操作思维,应该明确边界。
|
||||
|
||||
- `get_preferences`
|
||||
- `manage_preferences`
|
||||
|
||||
原因:
|
||||
|
||||
- 长期偏好记忆是辅助能力,不是交易任务主线
|
||||
- 如果让模型频繁自由改偏好,容易污染上下文
|
||||
|
||||
重构建议:
|
||||
|
||||
- 仅在用户明确表达“记住/修改/删除长期偏好”时使用
|
||||
- 不要把偏好系统混进交易执行和排障主流程
|
||||
|
||||
### 四、建议前移为 management / diagnosis skill 的现有高层工具
|
||||
|
||||
下面这些现有高层工具虽然可用,但语义仍然过宽,建议后续逐步前移为 management / diagnosis skill。
|
||||
|
||||
#### 1. `manage_trader`
|
||||
|
||||
建议逐步前移为:
|
||||
|
||||
- `trader_management`
|
||||
- `trader_diagnosis`
|
||||
|
||||
原因:
|
||||
|
||||
- 创建、修改、启动、停止、删除虽然动作不同,但属于同一资源管理域
|
||||
- 诊断路径和执行路径应分开
|
||||
|
||||
#### 2. `manage_exchange_config`
|
||||
|
||||
建议逐步前移为:
|
||||
|
||||
- `exchange_management`
|
||||
- `exchange_diagnosis`
|
||||
|
||||
原因:
|
||||
|
||||
- CRUD / query 属于同一资源管理域
|
||||
- invalid signature / timestamp / IP 白名单问题需要单独诊断路径
|
||||
|
||||
#### 3. `manage_model_config`
|
||||
|
||||
建议逐步前移为:
|
||||
|
||||
- `model_management`
|
||||
- `model_diagnosis`
|
||||
|
||||
原因:
|
||||
|
||||
- 模型对象管理应集中到一个 management skill
|
||||
- provider 配置失败和运行失败应集中到 diagnosis skill
|
||||
|
||||
#### 4. `manage_strategy`
|
||||
|
||||
建议逐步前移为:
|
||||
|
||||
- `strategy_management`
|
||||
- `strategy_diagnosis`
|
||||
|
||||
原因:
|
||||
|
||||
- 策略模板管理和策略问题排查是两类不同任务
|
||||
- create / update / activate / duplicate / delete / query 可以统一在 management skill 内处理
|
||||
|
||||
### 五、当前最适合直接做成硬 skill 的第一批对象
|
||||
|
||||
如果后续开始从“prompt 约束”走向“真正 dispatcher + skill runner”,建议优先落以下几类:
|
||||
|
||||
1. `create_trader`
|
||||
2. `trader_management`
|
||||
3. `exchange_management`
|
||||
4. `model_management`
|
||||
5. `exchange_diagnosis`
|
||||
6. `model_diagnosis`
|
||||
7. `trader_diagnosis`
|
||||
|
||||
原因:
|
||||
|
||||
- 这些最常见
|
||||
- 多轮价值最高
|
||||
- 失败成本高
|
||||
- 用户对稳定性的感知最强
|
||||
|
||||
### 六、最终目标
|
||||
|
||||
`agent/tools.go` 中的工具未来应逐步承担“skill 的执行底座”角色,而不是直接承担全部对话策略。
|
||||
|
||||
也就是说,长期理想状态是:
|
||||
|
||||
- 文档层:按 skill 组织
|
||||
- 对话层:先匹配 skill
|
||||
- 执行层:skill 内部复用现有 tool
|
||||
- planner 层:只兜底少数复杂情况
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
@@ -53,28 +52,16 @@ func (h *CryptoHandler) HandleGetPublicKey(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Encrypted Data Decryption Endpoint ====================
|
||||
|
||||
// HandleDecryptSensitiveData Decrypt encrypted data sent from client
|
||||
func (h *CryptoHandler) HandleDecryptSensitiveData(c *gin.Context) {
|
||||
var payload crypto.EncryptedPayload
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
decrypted, err := h.cryptoService.DecryptSensitiveData(&payload)
|
||||
if err != nil {
|
||||
log.Printf("❌ Decryption failed: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Decryption failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, map[string]string{
|
||||
"plaintext": decrypted,
|
||||
})
|
||||
}
|
||||
// ==================== Encrypted Data Decryption ====================
|
||||
//
|
||||
// SECURITY: there is deliberately NO public decrypt endpoint. Transport
|
||||
// encryption is one-directional — clients encrypt sensitive fields to the
|
||||
// server's RSA public key and the authenticated config-update handlers
|
||||
// (handleUpdateModelConfigs / handleUpdateExchangeConfigs / handleCreateExchange)
|
||||
// decrypt them server-side via cryptoService.DecryptSensitiveData. Exposing a
|
||||
// generic decrypt route would turn the server into a decryption oracle that any
|
||||
// unauthenticated caller could use to recover the plaintext of a captured
|
||||
// ciphertext, defeating the entire transport-encryption layer.
|
||||
|
||||
// ==================== Audit Log Query Endpoint ====================
|
||||
|
||||
|
||||
@@ -319,30 +319,24 @@ func accountAssetForExchange(exchangeType string) string {
|
||||
}
|
||||
|
||||
func missingExchangeCredentials(exchangeCfg *store.Exchange) (status string, code string, message string, missing bool) {
|
||||
switch exchangeCfg.ExchangeType {
|
||||
case "binance", "bybit", "gate", "indodax":
|
||||
if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key and secret key are required", true
|
||||
}
|
||||
case "okx", "bitget", "kucoin":
|
||||
if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" || exchangeCfg.Passphrase == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key, secret key, and passphrase are required", true
|
||||
}
|
||||
case "hyperliquid":
|
||||
if exchangeCfg.APIKey == "" || exchangeCfg.HyperliquidWalletAddr == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Private key and wallet address are required", true
|
||||
}
|
||||
case "aster":
|
||||
if exchangeCfg.AsterUser == "" || exchangeCfg.AsterSigner == "" || exchangeCfg.AsterPrivateKey == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Aster user, signer, and private key are required", true
|
||||
}
|
||||
case "lighter":
|
||||
if exchangeCfg.LighterWalletAddr == "" || exchangeCfg.LighterAPIKeyPrivateKey == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Wallet address and API key private key are required", true
|
||||
}
|
||||
default:
|
||||
missingFields := store.MissingRequiredExchangeCredentialFields(
|
||||
exchangeCfg.ExchangeType,
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
exchangeCfg.HyperliquidWalletAddr,
|
||||
exchangeCfg.AsterUser,
|
||||
exchangeCfg.AsterSigner,
|
||||
string(exchangeCfg.AsterPrivateKey),
|
||||
exchangeCfg.LighterWalletAddr,
|
||||
string(exchangeCfg.LighterAPIKeyPrivateKey),
|
||||
)
|
||||
if len(missingFields) > 0 {
|
||||
if len(missingFields) == 1 && missingFields[0] == "exchange_type" {
|
||||
return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type", true
|
||||
}
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Missing required fields: " + strings.Join(missingFields, ", "), true
|
||||
}
|
||||
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"nofx/crypto"
|
||||
"nofx/logger"
|
||||
"nofx/security"
|
||||
"nofx/store"
|
||||
"nofx/wallet"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -30,19 +31,26 @@ type SafeModelConfig struct {
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive)
|
||||
CustomModelName string `json:"customModelName"` // Custom model name (not sensitive)
|
||||
WalletAddress string `json:"walletAddress,omitempty"`
|
||||
BalanceUSDC string `json:"balanceUsdc,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateModelConfigRequest struct {
|
||||
Models map[string]struct {
|
||||
// ModelConfigUpdate is a single model's update payload. It is a named type
|
||||
// (rather than an inline anonymous struct) so the log-sanitizer in utils.go is
|
||||
// guaranteed to stay in sync with this shape — a mismatch there is what let
|
||||
// plaintext credentials reach the logs previously.
|
||||
type ModelConfigUpdate struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
CustomAPIURL string `json:"custom_api_url"`
|
||||
CustomModelName string `json:"custom_model_name"`
|
||||
} `json:"models"`
|
||||
}
|
||||
|
||||
type UpdateModelConfigRequest struct {
|
||||
Models map[string]ModelConfigUpdate `json:"models"`
|
||||
}
|
||||
|
||||
// handleGetModelConfigs Get AI model configurations
|
||||
@@ -60,14 +68,14 @@ func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
||||
if len(models) == 0 {
|
||||
logger.Infof("⚠️ No AI models in database, returning defaults")
|
||||
defaultModels := []SafeModelConfig{
|
||||
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false},
|
||||
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false},
|
||||
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false},
|
||||
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false},
|
||||
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false},
|
||||
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false},
|
||||
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false},
|
||||
{ID: "minimax", Name: "MiniMax AI", Provider: "minimax", Enabled: false},
|
||||
{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
|
||||
@@ -76,13 +84,17 @@ 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,
|
||||
Provider: model.Provider,
|
||||
Enabled: model.Enabled,
|
||||
HasAPIKey: model.APIKey != "",
|
||||
CustomAPIURL: model.CustomAPIURL,
|
||||
CustomModelName: model.CustomModelName,
|
||||
}
|
||||
@@ -98,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)
|
||||
@@ -171,7 +199,8 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
||||
if modelData.CustomAPIURL != "" {
|
||||
cleanURL := strings.TrimSuffix(modelData.CustomAPIURL, "#")
|
||||
if err := security.ValidateURL(cleanURL); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid custom_api_url for model %s: %s", modelID, err.Error())})
|
||||
logger.Warnf("Invalid custom_api_url for model %s: %v", modelID, err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid custom_api_url for model %s: URL must be a valid HTTPS endpoint", modelID)})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -202,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"})
|
||||
}
|
||||
|
||||
@@ -218,7 +247,9 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) {
|
||||
{"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": "claw402", "name": "Claw402 (Base USDC)", "provider": "claw402", "defaultModel": "glm-5"},
|
||||
{"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"},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, supportedModels)
|
||||
|
||||
@@ -119,12 +119,25 @@ func (s *Server) handleCompetition(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, competition)
|
||||
}
|
||||
|
||||
// handleEquityHistory Return rate historical data
|
||||
// Query directly from database, not dependent on trader in memory (so historical data can be retrieved after restart)
|
||||
// handleEquityHistory returns equity history for a trader. This endpoint is
|
||||
// PUBLIC (used by the competition leaderboard), so it cannot use the
|
||||
// authenticated getTraderFromQuery helper. Instead, it validates that the
|
||||
// requested trader has explicitly opted into the public competition via
|
||||
// show_in_competition=true. Traders without that flag are not exposed.
|
||||
func (s *Server) handleEquityHistory(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
traderID := c.Query("trader_id")
|
||||
if traderID == "" {
|
||||
SafeBadRequest(c, "trader_id is required")
|
||||
return
|
||||
}
|
||||
trader, err := s.store.Trader().GetByID(traderID)
|
||||
if err != nil || trader == nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
if !trader.ShowInCompetition {
|
||||
// Do not leak that a private trader exists; report not found.
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -152,34 +165,80 @@ func (s *Server) handleEquityHistory(c *gin.Context) {
|
||||
MarginUsedPct float64 `json:"margin_used_pct"` // Margin used percentage
|
||||
}
|
||||
|
||||
// Use the balance of the first record as initial balance to calculate return rate
|
||||
initialBalance := snapshots[0].Balance
|
||||
initialBalance := trader.InitialBalance
|
||||
if initialBalance <= 0 {
|
||||
initialBalance = snapshots[0].TotalEquity
|
||||
}
|
||||
if initialBalance == 0 {
|
||||
initialBalance = 1 // Avoid division by zero
|
||||
}
|
||||
|
||||
var history []EquityPoint
|
||||
var lastSnapshotTime time.Time
|
||||
for _, snap := range snapshots {
|
||||
// Calculate PnL percentage
|
||||
totalPnL := snap.TotalEquity - initialBalance
|
||||
totalPnLPct := 0.0
|
||||
if initialBalance > 0 {
|
||||
totalPnLPct = (snap.UnrealizedPnL / initialBalance) * 100
|
||||
totalPnLPct = (totalPnL / initialBalance) * 100
|
||||
}
|
||||
|
||||
history = append(history, EquityPoint{
|
||||
Timestamp: snap.Timestamp.Format("2006-01-02 15:04:05"),
|
||||
TotalEquity: snap.TotalEquity,
|
||||
AvailableBalance: snap.Balance,
|
||||
TotalPnL: snap.UnrealizedPnL,
|
||||
AvailableBalance: equitySnapshotAvailableBalance(snap),
|
||||
TotalPnL: totalPnL,
|
||||
TotalPnLPct: totalPnLPct,
|
||||
PositionCount: snap.PositionCount,
|
||||
MarginUsedPct: snap.MarginUsedPct,
|
||||
})
|
||||
if snap.Timestamp.After(lastSnapshotTime) {
|
||||
lastSnapshotTime = snap.Timestamp
|
||||
}
|
||||
}
|
||||
|
||||
if runtimeTrader, err := s.traderManager.GetTrader(traderID); err == nil {
|
||||
if accountInfo, err := runtimeTrader.GetAccountInfo(); err == nil && time.Since(lastSnapshotTime) > 30*time.Second {
|
||||
totalEquity := floatFromMap(accountInfo, "total_equity")
|
||||
totalPnL := totalEquity - initialBalance
|
||||
totalPnLPct := 0.0
|
||||
if initialBalance > 0 {
|
||||
totalPnLPct = (totalPnL / initialBalance) * 100
|
||||
}
|
||||
history = append(history, EquityPoint{
|
||||
Timestamp: time.Now().UTC().Format("2006-01-02 15:04:05"),
|
||||
TotalEquity: totalEquity,
|
||||
AvailableBalance: floatFromMap(accountInfo, "available_balance"),
|
||||
TotalPnL: totalPnL,
|
||||
TotalPnLPct: totalPnLPct,
|
||||
PositionCount: int(floatFromMap(accountInfo, "position_count")),
|
||||
MarginUsedPct: floatFromMap(accountInfo, "margin_used_pct"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, history)
|
||||
}
|
||||
|
||||
func equitySnapshotAvailableBalance(snap *store.EquitySnapshot) float64 {
|
||||
if snap == nil {
|
||||
return 0
|
||||
}
|
||||
if snap.AvailableBalance != 0 || snap.PositionCount > 0 {
|
||||
return snap.AvailableBalance
|
||||
}
|
||||
return snap.Balance
|
||||
}
|
||||
|
||||
func floatFromMap(values map[string]interface{}, key string) float64 {
|
||||
if value, ok := values[key].(float64); ok {
|
||||
return value
|
||||
}
|
||||
if value, ok := values[key].(int); ok {
|
||||
return float64(value)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// handlePublicTraderList Get public trader list (no authentication required)
|
||||
func (s *Server) handlePublicTraderList(c *gin.Context) {
|
||||
// Get trader information from all users
|
||||
@@ -373,16 +432,18 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[s
|
||||
history := make([]map[string]interface{}, 0, len(snapshots)+1)
|
||||
var lastSnapshotTime time.Time
|
||||
for _, snap := range snapshots {
|
||||
totalPnL := snap.TotalEquity - initialBalance
|
||||
// Calculate PnL percentage: (current_equity - initial_balance) / initial_balance * 100
|
||||
pnlPct := 0.0
|
||||
if initialBalance > 0 {
|
||||
pnlPct = (snap.TotalEquity - initialBalance) / initialBalance * 100
|
||||
pnlPct = totalPnL / initialBalance * 100
|
||||
}
|
||||
|
||||
history = append(history, map[string]interface{}{
|
||||
"timestamp": snap.Timestamp,
|
||||
"total_equity": snap.TotalEquity,
|
||||
"total_pnl": snap.UnrealizedPnL,
|
||||
"available_balance": equitySnapshotAvailableBalance(snap),
|
||||
"total_pnl": totalPnL,
|
||||
"total_pnl_pct": pnlPct,
|
||||
"balance": snap.Balance,
|
||||
})
|
||||
@@ -397,18 +458,9 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[s
|
||||
if accountInfo, err := trader.GetAccountInfo(); err == nil {
|
||||
// Only append if it's been more than 30 seconds since last snapshot
|
||||
if now.Sub(lastSnapshotTime) > 30*time.Second {
|
||||
totalEquity := 0.0
|
||||
if v, ok := accountInfo["total_equity"].(float64); ok {
|
||||
totalEquity = v
|
||||
}
|
||||
totalPnL := 0.0
|
||||
if v, ok := accountInfo["total_pnl"].(float64); ok {
|
||||
totalPnL = v
|
||||
}
|
||||
walletBalance := 0.0
|
||||
if v, ok := accountInfo["wallet_balance"].(float64); ok {
|
||||
walletBalance = v
|
||||
}
|
||||
totalEquity := floatFromMap(accountInfo, "total_equity")
|
||||
totalPnL := totalEquity - initialBalance
|
||||
walletBalance := floatFromMap(accountInfo, "wallet_balance")
|
||||
pnlPct := 0.0
|
||||
if initialBalance > 0 {
|
||||
pnlPct = (totalEquity - initialBalance) / initialBalance * 100
|
||||
@@ -417,6 +469,7 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[s
|
||||
history = append(history, map[string]interface{}{
|
||||
"timestamp": now,
|
||||
"total_equity": totalEquity,
|
||||
"available_balance": floatFromMap(accountInfo, "available_balance"),
|
||||
"total_pnl": totalPnL,
|
||||
"total_pnl_pct": pnlPct,
|
||||
"balance": walletBalance,
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -30,22 +32,58 @@ type SafeExchangeConfig struct {
|
||||
Name string `json:"name"` // Display name
|
||||
Type string `json:"type"` // "cex" or "dex"
|
||||
Enabled bool `json:"enabled"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
HasSecretKey bool `json:"has_secret_key"`
|
||||
HasPassphrase bool `json:"has_passphrase"`
|
||||
Testnet bool `json:"testnet,omitempty"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquidUnifiedAccount"`
|
||||
HyperliquidBuilderApproved bool `json:"hyperliquidBuilderApproved"`
|
||||
HasAsterPrivateKey bool `json:"has_aster_private_key"`
|
||||
AsterUser string `json:"asterUser"` // Aster username (not sensitive)
|
||||
AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
|
||||
LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive)
|
||||
HasLighterPrivateKey bool `json:"has_lighter_private_key"`
|
||||
HasLighterAPIKey bool `json:"has_lighter_api_key_private_key"`
|
||||
}
|
||||
|
||||
type UpdateExchangeConfigRequest struct {
|
||||
Exchanges map[string]struct {
|
||||
func safeExchangeConfigFromStore(exchange *store.Exchange) SafeExchangeConfig {
|
||||
return SafeExchangeConfig{
|
||||
ID: exchange.ID,
|
||||
ExchangeType: exchange.ExchangeType,
|
||||
AccountName: exchange.AccountName,
|
||||
Name: exchange.Name,
|
||||
Type: exchange.Type,
|
||||
Enabled: exchange.Enabled,
|
||||
HasAPIKey: exchange.APIKey != "",
|
||||
HasSecretKey: exchange.SecretKey != "",
|
||||
HasPassphrase: exchange.Passphrase != "",
|
||||
Testnet: exchange.Testnet,
|
||||
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
|
||||
HyperliquidUnifiedAcct: exchange.HyperliquidUnifiedAcct,
|
||||
HyperliquidBuilderApproved: exchange.HyperliquidBuilderApproved,
|
||||
HasAsterPrivateKey: exchange.AsterPrivateKey != "",
|
||||
AsterUser: exchange.AsterUser,
|
||||
AsterSigner: exchange.AsterSigner,
|
||||
LighterWalletAddr: exchange.LighterWalletAddr,
|
||||
HasLighterPrivateKey: exchange.LighterPrivateKey != "",
|
||||
HasLighterAPIKey: exchange.LighterAPIKeyPrivateKey != "",
|
||||
}
|
||||
}
|
||||
|
||||
// ExchangeConfigUpdate is a single exchange account's update payload. It is a
|
||||
// named type (rather than an inline anonymous struct) so the log-sanitizer in
|
||||
// utils.go is guaranteed to cover every sensitive field — a drift between the
|
||||
// two shapes is what let passphrases / private keys reach the logs previously.
|
||||
type ExchangeConfigUpdate struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Passphrase string `json:"passphrase"` // OKX specific
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
|
||||
HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode
|
||||
HyperliquidBuilderApproved *bool `json:"hyperliquid_builder_approved"`
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
@@ -53,7 +91,10 @@ type UpdateExchangeConfigRequest struct {
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||
} `json:"exchanges"`
|
||||
}
|
||||
|
||||
type UpdateExchangeConfigRequest struct {
|
||||
Exchanges map[string]ExchangeConfigUpdate `json:"exchanges"`
|
||||
}
|
||||
|
||||
// CreateExchangeRequest request structure for creating a new exchange account
|
||||
@@ -66,7 +107,8 @@ type CreateExchangeRequest struct {
|
||||
Passphrase string `json:"passphrase"`
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
|
||||
HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
|
||||
HyperliquidBuilderApproved bool `json:"hyperliquid_builder_approved"`
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
@@ -96,26 +138,30 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
|
||||
logger.Infof("✅ Found %d exchange configs", len(exchanges))
|
||||
|
||||
// Convert to safe response structure, remove sensitive information
|
||||
safeExchanges := make([]SafeExchangeConfig, len(exchanges))
|
||||
for i, exchange := range exchanges {
|
||||
safeExchanges[i] = SafeExchangeConfig{
|
||||
ID: exchange.ID,
|
||||
ExchangeType: exchange.ExchangeType,
|
||||
AccountName: exchange.AccountName,
|
||||
Name: exchange.Name,
|
||||
Type: exchange.Type,
|
||||
Enabled: exchange.Enabled,
|
||||
Testnet: exchange.Testnet,
|
||||
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
|
||||
AsterUser: exchange.AsterUser,
|
||||
AsterSigner: exchange.AsterSigner,
|
||||
LighterWalletAddr: exchange.LighterWalletAddr,
|
||||
safeExchanges := make([]SafeExchangeConfig, 0, len(exchanges))
|
||||
for _, exchange := range exchanges {
|
||||
if !store.IsVisibleExchange(exchange) {
|
||||
continue
|
||||
}
|
||||
safeExchanges = append(safeExchanges, safeExchangeConfigFromStore(exchange))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, safeExchanges)
|
||||
}
|
||||
|
||||
func effectiveHyperliquidUnifiedAccount(exchangeType string, requested *bool, fallback ...bool) bool {
|
||||
if requested != nil {
|
||||
return *requested
|
||||
}
|
||||
if strings.EqualFold(exchangeType, "hyperliquid") {
|
||||
if len(fallback) > 0 {
|
||||
return fallback[0]
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleUpdateExchangeConfigs Update exchange configurations (supports both encrypted and plain text based on config)
|
||||
func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
@@ -179,13 +225,83 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
// Update each exchange's configuration and track traders that need reload
|
||||
tradersToReload := make(map[string]bool)
|
||||
for exchangeID, exchangeData := range req.Exchanges {
|
||||
existing, err := s.store.Exchange().GetByID(userID, exchangeID)
|
||||
if err != nil {
|
||||
SafeInternalError(c, fmt.Sprintf("Load exchange %s", exchangeID), err)
|
||||
return
|
||||
}
|
||||
effectiveAPIKey := strings.TrimSpace(exchangeData.APIKey)
|
||||
if effectiveAPIKey == "" {
|
||||
effectiveAPIKey = strings.TrimSpace(string(existing.APIKey))
|
||||
}
|
||||
effectiveSecretKey := strings.TrimSpace(exchangeData.SecretKey)
|
||||
if effectiveSecretKey == "" {
|
||||
effectiveSecretKey = strings.TrimSpace(string(existing.SecretKey))
|
||||
}
|
||||
effectivePassphrase := strings.TrimSpace(exchangeData.Passphrase)
|
||||
if effectivePassphrase == "" {
|
||||
effectivePassphrase = strings.TrimSpace(string(existing.Passphrase))
|
||||
}
|
||||
effectiveAsterPrivateKey := strings.TrimSpace(exchangeData.AsterPrivateKey)
|
||||
if effectiveAsterPrivateKey == "" {
|
||||
effectiveAsterPrivateKey = strings.TrimSpace(string(existing.AsterPrivateKey))
|
||||
}
|
||||
effectiveLighterAPIKeyPrivateKey := strings.TrimSpace(exchangeData.LighterAPIKeyPrivateKey)
|
||||
if effectiveLighterAPIKeyPrivateKey == "" {
|
||||
effectiveLighterAPIKeyPrivateKey = strings.TrimSpace(string(existing.LighterAPIKeyPrivateKey))
|
||||
}
|
||||
effectiveHyperliquidWalletAddr := strings.TrimSpace(exchangeData.HyperliquidWalletAddr)
|
||||
if effectiveHyperliquidWalletAddr == "" {
|
||||
effectiveHyperliquidWalletAddr = strings.TrimSpace(existing.HyperliquidWalletAddr)
|
||||
}
|
||||
effectiveAsterUser := strings.TrimSpace(exchangeData.AsterUser)
|
||||
if effectiveAsterUser == "" {
|
||||
effectiveAsterUser = strings.TrimSpace(existing.AsterUser)
|
||||
}
|
||||
effectiveAsterSigner := strings.TrimSpace(exchangeData.AsterSigner)
|
||||
if effectiveAsterSigner == "" {
|
||||
effectiveAsterSigner = strings.TrimSpace(existing.AsterSigner)
|
||||
}
|
||||
effectiveLighterWalletAddr := strings.TrimSpace(exchangeData.LighterWalletAddr)
|
||||
if effectiveLighterWalletAddr == "" {
|
||||
effectiveLighterWalletAddr = strings.TrimSpace(existing.LighterWalletAddr)
|
||||
}
|
||||
effectiveHyperliquidBuilderApproved := existing.HyperliquidBuilderApproved
|
||||
if exchangeData.HyperliquidBuilderApproved != nil {
|
||||
effectiveHyperliquidBuilderApproved = *exchangeData.HyperliquidBuilderApproved
|
||||
}
|
||||
effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount(
|
||||
existing.ExchangeType,
|
||||
exchangeData.HyperliquidUnifiedAcct,
|
||||
existing.HyperliquidUnifiedAcct,
|
||||
)
|
||||
|
||||
if missing := store.MissingRequiredExchangeCredentialFields(
|
||||
existing.ExchangeType,
|
||||
effectiveAPIKey,
|
||||
effectiveSecretKey,
|
||||
effectivePassphrase,
|
||||
effectiveHyperliquidWalletAddr,
|
||||
effectiveAsterUser,
|
||||
effectiveAsterSigner,
|
||||
effectiveAsterPrivateKey,
|
||||
effectiveLighterWalletAddr,
|
||||
effectiveLighterAPIKeyPrivateKey,
|
||||
); len(missing) > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Missing required exchange fields: %s", strings.Join(missing, ", ")),
|
||||
"missing_fields": missing,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Find traders using this exchange BEFORE updating
|
||||
traders, _ := s.store.Trader().ListByExchangeID(userID, exchangeID)
|
||||
for _, t := range traders {
|
||||
tradersToReload[t.ID] = true
|
||||
}
|
||||
|
||||
err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
|
||||
err = s.store.Exchange().Update(userID, exchangeID, true, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, effectiveHyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, effectiveHyperliquidBuilderApproved, effectiveAsterUser, effectiveAsterSigner, exchangeData.AsterPrivateKey, effectiveLighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
|
||||
if err != nil {
|
||||
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
|
||||
return
|
||||
@@ -207,7 +323,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
// Don't return error here since exchange config was successfully updated to database
|
||||
}
|
||||
|
||||
logger.Infof("✓ Exchange config updated: %+v", req.Exchanges)
|
||||
logger.Infof("✓ Exchange config updated: %+v", SanitizeExchangeConfigForLog(req.Exchanges))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Exchange configuration updated"})
|
||||
}
|
||||
|
||||
@@ -271,12 +387,31 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)})
|
||||
return
|
||||
}
|
||||
if missing := store.MissingRequiredExchangeCredentialFields(
|
||||
req.ExchangeType,
|
||||
req.APIKey,
|
||||
req.SecretKey,
|
||||
req.Passphrase,
|
||||
req.HyperliquidWalletAddr,
|
||||
req.AsterUser,
|
||||
req.AsterSigner,
|
||||
req.AsterPrivateKey,
|
||||
req.LighterWalletAddr,
|
||||
req.LighterAPIKeyPrivateKey,
|
||||
); len(missing) > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Missing required exchange fields: %s", strings.Join(missing, ", ")),
|
||||
"missing_fields": missing,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create new exchange account
|
||||
// Exchange configs only persist once complete; persisted configs are always enabled.
|
||||
effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount(req.ExchangeType, req.HyperliquidUnifiedAcct)
|
||||
id, err := s.store.Exchange().Create(
|
||||
userID, req.ExchangeType, req.AccountName, req.Enabled,
|
||||
userID, req.ExchangeType, req.AccountName, true,
|
||||
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
|
||||
req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct,
|
||||
req.HyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, req.HyperliquidBuilderApproved,
|
||||
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
|
||||
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
|
||||
)
|
||||
|
||||
66
api/handler_exchange_test.go
Normal file
66
api/handler_exchange_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"nofx/crypto"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
func TestSafeExchangeConfigFromStoreIncludesCredentialPresenceFlags(t *testing.T) {
|
||||
cfg := &store.Exchange{
|
||||
ID: "ex-1",
|
||||
ExchangeType: "okx",
|
||||
AccountName: "OKX Main",
|
||||
Name: "OKX Main",
|
||||
Type: "cex",
|
||||
Enabled: true,
|
||||
APIKey: crypto.EncryptedString("api-test-123"),
|
||||
SecretKey: crypto.EncryptedString("secret-test-123"),
|
||||
Passphrase: crypto.EncryptedString("passphrase-test-123"),
|
||||
HyperliquidUnifiedAcct: true,
|
||||
AsterPrivateKey: crypto.EncryptedString("aster-private-key"),
|
||||
LighterPrivateKey: crypto.EncryptedString("lighter-private-key"),
|
||||
LighterAPIKeyPrivateKey: crypto.EncryptedString("lighter-api-key-private-key"),
|
||||
}
|
||||
|
||||
safe := safeExchangeConfigFromStore(cfg)
|
||||
if !safe.HasAPIKey {
|
||||
t.Fatalf("expected has_api_key to be true")
|
||||
}
|
||||
if !safe.HasSecretKey {
|
||||
t.Fatalf("expected has_secret_key to be true")
|
||||
}
|
||||
if !safe.HasPassphrase {
|
||||
t.Fatalf("expected has_passphrase to be true")
|
||||
}
|
||||
if !safe.HasAsterPrivateKey {
|
||||
t.Fatalf("expected has_aster_private_key to be true")
|
||||
}
|
||||
if !safe.HasLighterPrivateKey {
|
||||
t.Fatalf("expected has_lighter_private_key to be true")
|
||||
}
|
||||
if !safe.HasLighterAPIKey {
|
||||
t.Fatalf("expected has_lighter_api_key_private_key to be true")
|
||||
}
|
||||
if !safe.HyperliquidUnifiedAcct {
|
||||
t.Fatalf("expected hyperliquid unified account to be exposed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveHyperliquidUnifiedAccountDefaultsAndPreserves(t *testing.T) {
|
||||
if !effectiveHyperliquidUnifiedAccount("hyperliquid", nil) {
|
||||
t.Fatalf("expected new hyperliquid accounts to default unified account on")
|
||||
}
|
||||
if effectiveHyperliquidUnifiedAccount("binance", nil) {
|
||||
t.Fatalf("expected non-hyperliquid accounts to default unified account off")
|
||||
}
|
||||
fallbackFalse := effectiveHyperliquidUnifiedAccount("hyperliquid", nil, false)
|
||||
if fallbackFalse {
|
||||
t.Fatalf("expected omitted update field to preserve existing false value")
|
||||
}
|
||||
requestedTrue := true
|
||||
if !effectiveHyperliquidUnifiedAccount("hyperliquid", &requestedTrue, false) {
|
||||
t.Fatalf("expected explicit true to override existing false value")
|
||||
}
|
||||
}
|
||||
413
api/handler_hyperliquid_wallet.go
Normal file
413
api/handler_hyperliquid_wallet.go
Normal file
@@ -0,0 +1,413 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHyperliquidBuilderAddress = "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d"
|
||||
// 0.05% (5 bps) — matches BuilderInfo.Fee=50 charged at order placement.
|
||||
// New wallet approvals sign this exact value; existing approvals at the
|
||||
// prior 0.1% cap remain valid because 0.05% is within their approved max.
|
||||
defaultHyperliquidBuilderMaxFee = "0.05%"
|
||||
hyperliquidExchangeURL = "https://api.hyperliquid.xyz/exchange"
|
||||
hyperliquidInfoURL = "https://api.hyperliquid.xyz/info"
|
||||
// nofxHyperliquidAgentName must match AGENT_NAME used by the frontend
|
||||
// approveAgent flow so we can locate the NOFX-managed agent on-chain.
|
||||
nofxHyperliquidAgentName = "NOFX Agent"
|
||||
)
|
||||
|
||||
type hyperliquidSubmitRequest struct {
|
||||
Action map[string]any `json:"action" binding:"required"`
|
||||
Nonce int64 `json:"nonce" binding:"required"`
|
||||
Signature struct {
|
||||
R string `json:"r" binding:"required"`
|
||||
S string `json:"s" binding:"required"`
|
||||
V int `json:"v"`
|
||||
} `json:"signature" binding:"required"`
|
||||
}
|
||||
|
||||
type hyperliquidConfigResponse struct {
|
||||
BuilderAddress string `json:"builderAddress"`
|
||||
BuilderMaxFee string `json:"builderMaxFee"`
|
||||
Chain string `json:"chain"`
|
||||
SignatureChain string `json:"signatureChainId"`
|
||||
}
|
||||
|
||||
type hyperliquidAccountSummary struct {
|
||||
Address string `json:"address"`
|
||||
AccountValue float64 `json:"accountValue"`
|
||||
Withdrawable float64 `json:"withdrawable"`
|
||||
TotalMarginUsed float64 `json:"totalMarginUsed"`
|
||||
UnrealizedPnl float64 `json:"unrealizedPnl"`
|
||||
OpenPositions int `json:"openPositions"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type hyperliquidAgentInfo struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
ValidUntil int64 `json:"validUntil"` // unix milliseconds
|
||||
}
|
||||
|
||||
type hyperliquidAgentResponse struct {
|
||||
// Agent is the NOFX-managed agent ("NOFX Agent"), nil when none is approved.
|
||||
Agent *hyperliquidAgentInfo `json:"agent"`
|
||||
// Agents lists every approved agent for the wallet (for visibility/cleanup).
|
||||
Agents []hyperliquidAgentInfo `json:"agents"`
|
||||
}
|
||||
|
||||
type hyperliquidClearinghouseState struct {
|
||||
MarginSummary struct {
|
||||
AccountValue string `json:"accountValue"`
|
||||
TotalMarginUsed string `json:"totalMarginUsed"`
|
||||
} `json:"marginSummary"`
|
||||
CrossMarginSummary struct {
|
||||
AccountValue string `json:"accountValue"`
|
||||
TotalMarginUsed string `json:"totalMarginUsed"`
|
||||
} `json:"crossMarginSummary"`
|
||||
Withdrawable string `json:"withdrawable"`
|
||||
AssetPositions []struct {
|
||||
Position struct {
|
||||
Szi string `json:"szi"`
|
||||
UnrealizedPnl string `json:"unrealizedPnl"`
|
||||
} `json:"position"`
|
||||
} `json:"assetPositions"`
|
||||
}
|
||||
|
||||
// agentValidUntilSuffix matches the " valid_until <ms>" suffix Hyperliquid uses
|
||||
// to encode an agent's expiry inside the agent name. Hyperliquid normally strips
|
||||
// it from the stored name, but we strip defensively before matching the slot.
|
||||
var agentValidUntilSuffix = regexp.MustCompile(` valid_until \d+$`)
|
||||
|
||||
func baseAgentName(name string) string {
|
||||
return strings.TrimSpace(agentValidUntilSuffix.ReplaceAllString(name, ""))
|
||||
}
|
||||
|
||||
func hyperliquidBuilderAddress() string {
|
||||
return defaultHyperliquidBuilderAddress
|
||||
}
|
||||
|
||||
func hyperliquidBuilderMaxFee() string {
|
||||
return defaultHyperliquidBuilderMaxFee
|
||||
}
|
||||
|
||||
func (s *Server) handleHyperliquidConnectConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, hyperliquidConfigResponse{
|
||||
BuilderAddress: hyperliquidBuilderAddress(),
|
||||
BuilderMaxFee: hyperliquidBuilderMaxFee(),
|
||||
Chain: "Mainnet",
|
||||
SignatureChain: "0x66eee",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHyperliquidAccount(c *gin.Context) {
|
||||
address := strings.ToLower(strings.TrimSpace(c.Query("address")))
|
||||
if !isEVMAddress(address) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"})
|
||||
return
|
||||
}
|
||||
|
||||
requestBody := map[string]any{
|
||||
"type": "clearinghouseState",
|
||||
"user": address,
|
||||
}
|
||||
body, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid balance request"})
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid balance request"})
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the balance request", "status": resp.StatusCode})
|
||||
return
|
||||
}
|
||||
|
||||
var state hyperliquidClearinghouseState
|
||||
if err := json.Unmarshal(respBody, &state); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid balance response"})
|
||||
return
|
||||
}
|
||||
|
||||
accountValue := parseFloatOrZero(state.MarginSummary.AccountValue)
|
||||
if accountValue == 0 {
|
||||
accountValue = parseFloatOrZero(state.CrossMarginSummary.AccountValue)
|
||||
}
|
||||
marginUsed := parseFloatOrZero(state.MarginSummary.TotalMarginUsed)
|
||||
if marginUsed == 0 {
|
||||
marginUsed = parseFloatOrZero(state.CrossMarginSummary.TotalMarginUsed)
|
||||
}
|
||||
|
||||
var unrealizedPnl float64
|
||||
openPositions := 0
|
||||
for _, position := range state.AssetPositions {
|
||||
size := parseFloatOrZero(position.Position.Szi)
|
||||
if size != 0 {
|
||||
openPositions++
|
||||
}
|
||||
unrealizedPnl += parseFloatOrZero(position.Position.UnrealizedPnl)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, hyperliquidAccountSummary{
|
||||
Address: address,
|
||||
AccountValue: accountValue,
|
||||
Withdrawable: parseFloatOrZero(state.Withdrawable),
|
||||
TotalMarginUsed: marginUsed,
|
||||
UnrealizedPnl: unrealizedPnl,
|
||||
OpenPositions: openPositions,
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleHyperliquidAgent reports the on-chain approved agents for a wallet,
|
||||
// including the NOFX agent's validUntil so the UI can show the expiry date and
|
||||
// warn before the 180-day authorization lapses.
|
||||
func (s *Server) handleHyperliquidAgent(c *gin.Context) {
|
||||
address := strings.ToLower(strings.TrimSpace(c.Query("address")))
|
||||
if !isEVMAddress(address) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := json.Marshal(map[string]any{"type": "extraAgents", "user": address})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid agent request"})
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid agent request"})
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the agent request", "status": resp.StatusCode})
|
||||
return
|
||||
}
|
||||
|
||||
// extraAgents returns null when no agents are approved.
|
||||
agents := []hyperliquidAgentInfo{}
|
||||
if len(respBody) > 0 && string(bytes.TrimSpace(respBody)) != "null" {
|
||||
if err := json.Unmarshal(respBody, &agents); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid agent response"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
out := hyperliquidAgentResponse{Agents: agents}
|
||||
for i := range agents {
|
||||
if strings.EqualFold(baseAgentName(agents[i].Name), nofxHyperliquidAgentName) {
|
||||
agent := agents[i]
|
||||
out.Agent = &agent
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleHyperliquidSubmitExchange(c *gin.Context) {
|
||||
var req hyperliquidSubmitRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid submit payload"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateSubmittedNonce(req.Action, req.Nonce); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
actionType, _ := req.Action["type"].(string)
|
||||
switch actionType {
|
||||
case "approveAgent":
|
||||
if err := validateApproveAgentAction(req.Action); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
case "approveBuilderFee":
|
||||
if err := validateApproveBuilderFeeAction(req.Action); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported Hyperliquid action"})
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"action": req.Action,
|
||||
"nonce": req.Nonce,
|
||||
"signature": req.Signature,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid payload"})
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
hlReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidExchangeURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid request"})
|
||||
return
|
||||
}
|
||||
hlReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(hlReq)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var decoded any
|
||||
if len(respBody) > 0 {
|
||||
_ = json.Unmarshal(respBody, &decoded)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the action", "status": resp.StatusCode, "response": decoded})
|
||||
return
|
||||
}
|
||||
|
||||
// Hyperliquid returns HTTP 200 even for logical failures, signalling them via
|
||||
// {"status":"err","response":"<message>"}. Without this check a rejected
|
||||
// approval (e.g. valid_until past the cap, or an unchanged agent) is reported
|
||||
// to the user as success while nothing changes on-chain.
|
||||
var hlResp struct {
|
||||
Status string `json:"status"`
|
||||
Response json.RawMessage `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &hlResp); err == nil && strings.EqualFold(hlResp.Status, "err") {
|
||||
msg := strings.TrimSpace(strings.Trim(string(hlResp.Response), `"`))
|
||||
if msg == "" {
|
||||
msg = "Hyperliquid rejected the action"
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": msg, "response": decoded})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "response": decoded})
|
||||
}
|
||||
|
||||
func validateApproveAgentAction(action map[string]any) error {
|
||||
if strings.TrimSpace(fmt.Sprint(action["agentAddress"])) == "" {
|
||||
return fmt.Errorf("missing agentAddress")
|
||||
}
|
||||
if strings.TrimSpace(fmt.Sprint(action["agentName"])) == "" {
|
||||
return fmt.Errorf("missing agentName")
|
||||
}
|
||||
return validateCommonHyperliquidSignedAction(action)
|
||||
}
|
||||
|
||||
func validateApproveBuilderFeeAction(action map[string]any) error {
|
||||
builder := strings.ToLower(strings.TrimSpace(fmt.Sprint(action["builder"])))
|
||||
if builder != hyperliquidBuilderAddress() {
|
||||
return fmt.Errorf("builder address mismatch")
|
||||
}
|
||||
if strings.TrimSpace(fmt.Sprint(action["maxFeeRate"])) != hyperliquidBuilderMaxFee() {
|
||||
return fmt.Errorf("builder max fee mismatch")
|
||||
}
|
||||
return validateCommonHyperliquidSignedAction(action)
|
||||
}
|
||||
|
||||
func validateCommonHyperliquidSignedAction(action map[string]any) error {
|
||||
if strings.TrimSpace(fmt.Sprint(action["signatureChainId"])) != "0x66eee" {
|
||||
return fmt.Errorf("invalid signatureChainId")
|
||||
}
|
||||
if strings.TrimSpace(fmt.Sprint(action["hyperliquidChain"])) != "Mainnet" {
|
||||
return fmt.Errorf("invalid hyperliquidChain")
|
||||
}
|
||||
if _, err := actionNonce(action); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSubmittedNonce(action map[string]any, submitted int64) error {
|
||||
actionValue, err := actionNonce(action)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if actionValue != submitted {
|
||||
return fmt.Errorf("nonce mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isEVMAddress(address string) bool {
|
||||
if len(address) != 42 || !strings.HasPrefix(address, "0x") {
|
||||
return false
|
||||
}
|
||||
for _, char := range address[2:] {
|
||||
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseFloatOrZero(value string) float64 {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func actionNonce(action map[string]any) (int64, error) {
|
||||
raw, ok := action["nonce"]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("missing nonce")
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case float64:
|
||||
return int64(value), nil
|
||||
case int64:
|
||||
return value, nil
|
||||
case json.Number:
|
||||
return value.Int64()
|
||||
case string:
|
||||
return strconv.ParseInt(value, 10, 64)
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid nonce")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -320,61 +321,207 @@ func (s *Server) getKlinesFromHyperliquid(symbol, interval string, limit int) ([
|
||||
return klines, nil
|
||||
}
|
||||
|
||||
func hyperliquidXYZDisplayBase(baseSymbol string) string {
|
||||
baseSymbol = strings.ToUpper(strings.TrimSpace(baseSymbol))
|
||||
// User-facing names should be product names, not exchange shorthand tickers.
|
||||
// Keep the internal symbol separate because Hyperliquid's xyz dex still routes
|
||||
// orders/candles by the short coin name (for example xyz:SMSN).
|
||||
fullNames := map[string]string{
|
||||
"XYZ100": "XYZ100",
|
||||
"TSLA": "TESLA",
|
||||
"NVDA": "NVIDIA",
|
||||
"GOLD": "GOLD",
|
||||
"HOOD": "ROBINHOOD",
|
||||
"INTC": "INTEL",
|
||||
"PLTR": "PALANTIR",
|
||||
"COIN": "COINBASE",
|
||||
"META": "META",
|
||||
"AAPL": "APPLE",
|
||||
"MSFT": "MICROSOFT",
|
||||
"ORCL": "ORACLE",
|
||||
"GOOGL": "GOOGLE",
|
||||
"AMZN": "AMAZON",
|
||||
"AMD": "AMD",
|
||||
"MU": "MICRON",
|
||||
"SNDK": "SANDISK",
|
||||
"MSTR": "MICROSTRATEGY",
|
||||
"CRCL": "CIRCLE",
|
||||
"NFLX": "NETFLIX",
|
||||
"COST": "COSTCO",
|
||||
"LLY": "ELI-LILLY",
|
||||
"SKHX": "SK-HYNIX",
|
||||
"TSM": "TSMC",
|
||||
"JPY": "JPY",
|
||||
"EUR": "EUR",
|
||||
"SILVER": "SILVER",
|
||||
"RIVN": "RIVIAN",
|
||||
"BABA": "ALIBABA",
|
||||
"CL": "CRUDE-OIL",
|
||||
"COPPER": "COPPER",
|
||||
"NATGAS": "NATURAL-GAS",
|
||||
"URANIUM": "URANIUM",
|
||||
"ALUMINIUM": "ALUMINIUM",
|
||||
"SMSN": "SAMSUNG",
|
||||
"PLATINUM": "PLATINUM",
|
||||
"USAR": "USA-RARE-EARTH",
|
||||
"CRWV": "COREWEAVE",
|
||||
"URNM": "URNM",
|
||||
"PALLADIUM": "PALLADIUM",
|
||||
"DXY": "DOLLAR-INDEX",
|
||||
"GME": "GAMESTOP",
|
||||
"KR200": "KOREA-200",
|
||||
"SOFTBANK": "SOFTBANK",
|
||||
"JP225": "JAPAN-225",
|
||||
"HYUNDAI": "HYUNDAI",
|
||||
"KIOXIA": "KIOXIA",
|
||||
"EWY": "SOUTH-KOREA-ETF",
|
||||
"EWJ": "JAPAN-ETF",
|
||||
"BRENTOIL": "BRENT-OIL",
|
||||
"VIX": "VIX",
|
||||
"HIMS": "HIMS-HERS",
|
||||
"SP500": "S&P-500",
|
||||
"DKNG": "DRAFTKINGS",
|
||||
"LITE": "LITECOIN",
|
||||
"CORN": "CORN",
|
||||
"XLE": "ENERGY-SECTOR-ETF",
|
||||
"WHEAT": "WHEAT",
|
||||
"TTF": "TTF-GAS",
|
||||
"BX": "BLACKSTONE",
|
||||
"PURRDAT": "PURRDAT",
|
||||
"MRVL": "MARVELL",
|
||||
"RKLB": "ROCKET-LAB",
|
||||
"BIRD": "BIRD",
|
||||
"VOL": "VOLATILITY",
|
||||
"DRAM": "DRAM",
|
||||
"CBRS": "COINBASE-PRE-IPO",
|
||||
"EWZ": "BRAZIL-ETF",
|
||||
"KRW": "KRW",
|
||||
"ZM": "ZOOM",
|
||||
"EBAY": "EBAY",
|
||||
"H100": "H100",
|
||||
"NIFTY": "NIFTY-50",
|
||||
"ARM": "ARM",
|
||||
"EWT": "TAIWAN-ETF",
|
||||
"GBP": "GBP",
|
||||
"SPCX": "SPACEX-PRE-IPO",
|
||||
"IBOV": "IBOVESPA",
|
||||
"ASML": "ASML",
|
||||
}
|
||||
if fullName, ok := fullNames[baseSymbol]; ok {
|
||||
return fullName
|
||||
}
|
||||
return baseSymbol
|
||||
}
|
||||
|
||||
func hyperliquidXYZCategory(baseSymbol string) string {
|
||||
baseSymbol = strings.ToUpper(strings.TrimSpace(baseSymbol))
|
||||
switch baseSymbol {
|
||||
case "GOLD", "SILVER", "CL", "COPPER", "NATGAS", "URANIUM", "ALUMINIUM", "PLATINUM", "PALLADIUM", "BRENTOIL", "CORN", "WHEAT", "TTF":
|
||||
return "commodity"
|
||||
case "XYZ100", "SP500", "JP225", "KR200", "DXY", "VIX", "XLE", "EWY", "EWJ", "EWZ", "EWT", "NIFTY", "IBOV":
|
||||
return "index"
|
||||
case "EUR", "JPY", "GBP", "KRW":
|
||||
return "forex"
|
||||
case "SPCX", "BIRD", "PURRDAT", "H100", "CBRS":
|
||||
return "pre_ipo"
|
||||
default:
|
||||
return "stock"
|
||||
}
|
||||
}
|
||||
|
||||
func hyperliquidCategoryOrder(category string) int {
|
||||
switch category {
|
||||
case "stock":
|
||||
return 0
|
||||
case "commodity":
|
||||
return 1
|
||||
case "index":
|
||||
return 2
|
||||
case "forex":
|
||||
return 3
|
||||
case "pre_ipo":
|
||||
return 4
|
||||
case "crypto":
|
||||
return 5
|
||||
default:
|
||||
return 99
|
||||
}
|
||||
}
|
||||
|
||||
// handleSymbols returns available symbols for a given exchange
|
||||
func (s *Server) handleSymbols(c *gin.Context) {
|
||||
exchange := c.DefaultQuery("exchange", "hyperliquid")
|
||||
|
||||
type SymbolInfo struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Display string `json:"display"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"` // crypto, stock, forex, commodity, index
|
||||
Exchange string `json:"exchange"`
|
||||
Volume24h float64 `json:"volume_24h"`
|
||||
MarkPrice float64 `json:"mark_price"`
|
||||
PrevDayPrice float64 `json:"prev_day_price,omitempty"`
|
||||
Change24hPct float64 `json:"change_24h_pct,omitempty"`
|
||||
MaxLeverage int `json:"maxLeverage,omitempty"`
|
||||
SzDecimals int `json:"sz_decimals,omitempty"`
|
||||
}
|
||||
|
||||
var symbols []SymbolInfo
|
||||
|
||||
switch strings.ToLower(exchange) {
|
||||
exchangeLower := strings.ToLower(exchange)
|
||||
switch exchangeLower {
|
||||
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
||||
// Fetch symbols from Hyperliquid
|
||||
client := hyperliquid.NewClient()
|
||||
ctx := context.Background()
|
||||
|
||||
// Get crypto perps from default dex
|
||||
if exchange == "hyperliquid" || exchange == "hyperliquid-xyz" {
|
||||
mids, err := client.GetAllMids(ctx)
|
||||
if err == nil {
|
||||
for symbol := range mids {
|
||||
// Skip spot tokens (start with @)
|
||||
if strings.HasPrefix(symbol, "@") {
|
||||
continue
|
||||
// hyperliquid-xyz returns the full USDC trading board in product order:
|
||||
// stocks → commodities → indices → forex → pre-IPO → crypto.
|
||||
if exchangeLower == "hyperliquid-xyz" || exchangeLower == "xyz" {
|
||||
xyzCoins, err := hyperliquid.GetPerpDexCoins(ctx, hyperliquid.XYZDex)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get Hyperliquid XYZ symbols", err)
|
||||
return
|
||||
}
|
||||
for _, coin := range xyzCoins {
|
||||
baseSymbol := strings.TrimPrefix(coin.Symbol, "xyz:")
|
||||
displayBase := hyperliquidXYZDisplayBase(baseSymbol)
|
||||
displaySymbol := displayBase + "-USDC"
|
||||
tradeSymbol := baseSymbol + "-USDC"
|
||||
symbols = append(symbols, SymbolInfo{
|
||||
Symbol: symbol,
|
||||
Name: symbol,
|
||||
Category: "crypto",
|
||||
Symbol: tradeSymbol,
|
||||
Display: displaySymbol,
|
||||
Name: displayBase,
|
||||
Category: hyperliquidXYZCategory(baseSymbol),
|
||||
Exchange: "hyperliquid-xyz",
|
||||
Volume24h: coin.Volume24h,
|
||||
MarkPrice: coin.MarkPrice,
|
||||
PrevDayPrice: coin.PrevDayPrice,
|
||||
Change24hPct: coin.Change24hPct,
|
||||
MaxLeverage: coin.MaxLeverage,
|
||||
SzDecimals: coin.SzDecimals,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get xyz dex symbols (stocks, forex, commodities)
|
||||
xyzMids, err := client.GetAllMidsXYZ(ctx)
|
||||
if err == nil {
|
||||
for symbol := range xyzMids {
|
||||
// Remove xyz: prefix for display
|
||||
displaySymbol := strings.TrimPrefix(symbol, "xyz:")
|
||||
category := "stock"
|
||||
if displaySymbol == "GOLD" || displaySymbol == "SILVER" {
|
||||
category = "commodity"
|
||||
} else if displaySymbol == "EUR" || displaySymbol == "JPY" {
|
||||
category = "forex"
|
||||
} else if displaySymbol == "XYZ100" {
|
||||
category = "index"
|
||||
// Crypto perps are shown last; only include them on the combined Hyperliquid board.
|
||||
if exchangeLower == "hyperliquid" || exchangeLower == "hyperliquid-xyz" {
|
||||
coins, err := hyperliquid.GetProvider().GetAllCoins(ctx)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get Hyperliquid symbols", err)
|
||||
return
|
||||
}
|
||||
for _, coin := range coins {
|
||||
symbols = append(symbols, SymbolInfo{
|
||||
Symbol: displaySymbol,
|
||||
Name: displaySymbol,
|
||||
Category: category,
|
||||
Symbol: coin.Symbol,
|
||||
Display: coin.Symbol,
|
||||
Name: coin.Symbol,
|
||||
Category: "crypto",
|
||||
Exchange: "hyperliquid",
|
||||
Volume24h: coin.Volume24h,
|
||||
MarkPrice: coin.MarkPrice,
|
||||
PrevDayPrice: coin.PrevDayPrice,
|
||||
Change24hPct: coin.Change24hPct,
|
||||
MaxLeverage: coin.MaxLeverage,
|
||||
SzDecimals: coin.SzDecimals,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -384,6 +531,15 @@ func (s *Server) handleSymbols(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
sort.SliceStable(symbols, func(i, j int) bool {
|
||||
ci := hyperliquidCategoryOrder(symbols[i].Category)
|
||||
cj := hyperliquidCategoryOrder(symbols[j].Category)
|
||||
if ci != cj {
|
||||
return ci < cj
|
||||
}
|
||||
return symbols[i].Volume24h > symbols[j].Volume24h
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"exchange": exchange,
|
||||
"symbols": symbols,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/mcp/payment"
|
||||
"nofx/wallet"
|
||||
|
||||
gethcrypto "github.com/ethereum/go-ethereum/crypto"
|
||||
@@ -25,6 +26,7 @@ type beginnerOnboardingResponse struct {
|
||||
DefaultModel string `json:"default_model"`
|
||||
ConfiguredModelID string `json:"configured_model_id"`
|
||||
BalanceUSDC string `json:"balance_usdc"`
|
||||
BalanceStatus string `json:"balance_status,omitempty"`
|
||||
EnvSaved bool `json:"env_saved"`
|
||||
EnvPath string `json:"env_path,omitempty"`
|
||||
ReusedExisting bool `json:"reused_existing"`
|
||||
@@ -35,10 +37,22 @@ type currentBeginnerWalletResponse struct {
|
||||
Found bool `json:"found"`
|
||||
Address string `json:"address,omitempty"`
|
||||
BalanceUSDC string `json:"balance_usdc,omitempty"`
|
||||
BalanceStatus string `json:"balance_status,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Claw402Status string `json:"claw402_status"`
|
||||
}
|
||||
|
||||
// queryBeginnerWalletBalance returns the wallet balance plus a status flag so
|
||||
// the UI can distinguish "RPC unreachable" from a genuinely empty wallet.
|
||||
// Uses the 30s cache — safe for UI polling.
|
||||
func queryBeginnerWalletBalance(address string) (balanceUSDC string, balanceStatus string) {
|
||||
balance, err := wallet.QueryUSDCBalanceCached(address)
|
||||
if err != nil {
|
||||
return "", "unknown"
|
||||
}
|
||||
return fmt.Sprintf("%.2f", balance), "ok"
|
||||
}
|
||||
|
||||
func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
@@ -54,7 +68,7 @@ func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
|
||||
}
|
||||
|
||||
if !reusedExisting {
|
||||
if err := s.store.AIModel().Update(userID, "claw402", true, privateKey, "", "glm-5"); err != nil {
|
||||
if err := s.store.AIModel().Update(userID, "claw402", true, privateKey, "", payment.DefaultClaw402Model); err != nil {
|
||||
logger.Errorf("Failed to save beginner claw402 config for user %s: %v", userID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save beginner model configuration"})
|
||||
return
|
||||
@@ -68,18 +82,20 @@ func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
|
||||
|
||||
os.Setenv("CLAW402_WALLET_KEY", privateKey)
|
||||
os.Setenv("CLAW402_WALLET_ADDRESS", address)
|
||||
os.Setenv("CLAW402_DEFAULT_MODEL", "glm-5")
|
||||
os.Setenv("CLAW402_DEFAULT_MODEL", payment.DefaultClaw402Model)
|
||||
|
||||
envSaved, envPath, envErr := persistBeginnerWalletEnv(privateKey, address)
|
||||
balanceUSDC, balanceStatus := queryBeginnerWalletBalance(address)
|
||||
resp := beginnerOnboardingResponse{
|
||||
Address: address,
|
||||
PrivateKey: privateKey,
|
||||
Chain: "base",
|
||||
Asset: "USDC",
|
||||
Provider: "claw402",
|
||||
DefaultModel: "glm-5",
|
||||
DefaultModel: payment.DefaultClaw402Model,
|
||||
ConfiguredModelID: configuredModelID,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
BalanceUSDC: balanceUSDC,
|
||||
BalanceStatus: balanceStatus,
|
||||
EnvSaved: envSaved,
|
||||
EnvPath: envPath,
|
||||
ReusedExisting: reusedExisting,
|
||||
@@ -123,10 +139,12 @@ func (s *Server) handleCurrentBeginnerWallet(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
balanceUSDC, balanceStatus := queryBeginnerWalletBalance(address)
|
||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
||||
Found: true,
|
||||
Address: address,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
BalanceUSDC: balanceUSDC,
|
||||
BalanceStatus: balanceStatus,
|
||||
Source: "model",
|
||||
Claw402Status: claw402Status,
|
||||
})
|
||||
@@ -135,10 +153,12 @@ func (s *Server) handleCurrentBeginnerWallet(c *gin.Context) {
|
||||
|
||||
address := strings.TrimSpace(os.Getenv("CLAW402_WALLET_ADDRESS"))
|
||||
if address != "" {
|
||||
balanceUSDC, balanceStatus := queryBeginnerWalletBalance(address)
|
||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
||||
Found: true,
|
||||
Address: address,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
BalanceUSDC: balanceUSDC,
|
||||
BalanceStatus: balanceStatus,
|
||||
Source: "env",
|
||||
Claw402Status: claw402Status,
|
||||
})
|
||||
@@ -253,7 +273,7 @@ func persistBeginnerWalletEnv(privateKey string, address string) (bool, string,
|
||||
if err := upsertEnvFile(path, map[string]string{
|
||||
"CLAW402_WALLET_KEY": privateKey,
|
||||
"CLAW402_WALLET_ADDRESS": address,
|
||||
"CLAW402_DEFAULT_MODEL": "glm-5",
|
||||
"CLAW402_DEFAULT_MODEL": payment.DefaultClaw402Model,
|
||||
}); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
@@ -206,21 +207,43 @@ func (s *Server) handlePositionHistory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
if fullCfg, cfgErr := s.store.Trader().GetFullConfig(userID, traderID); cfgErr == nil && fullCfg.Exchange != nil {
|
||||
if syncErr := s.syncOrdersFromExchange(
|
||||
trader.GetUnderlyingTrader(),
|
||||
trader.GetID(),
|
||||
fullCfg.Exchange.ID,
|
||||
fullCfg.Exchange.ExchangeType,
|
||||
); syncErr != nil {
|
||||
logger.Infof("⚠️ Position history refresh sync skipped: %v", syncErr)
|
||||
}
|
||||
}
|
||||
|
||||
traderIDs := []string{trader.GetID()}
|
||||
var traderIDPatterns []string
|
||||
if strings.EqualFold(strings.TrimSpace(trader.GetName()), "NOFX Autopilot") && strings.TrimSpace(userID) != "" {
|
||||
// Older one-click launches created new Autopilot trader rows. When a row was
|
||||
// deleted, its closed position records remained under the old generated ID.
|
||||
// The generated Autopilot ID embeds userID + "claw402", so this safely
|
||||
// restores same-user history continuity without joining deleted rows.
|
||||
traderIDPatterns = append(traderIDPatterns, "%_"+userID+"_claw402_%")
|
||||
}
|
||||
|
||||
// Get closed positions
|
||||
positions, err := store.Position().GetClosedPositions(trader.GetID(), limit)
|
||||
positions, err := store.Position().GetClosedPositionsByTraderFilters(traderIDs, traderIDPatterns, limit)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get position history", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get statistics
|
||||
stats, _ := store.Position().GetFullStats(trader.GetID())
|
||||
stats, _ := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns, trader.GetInitialBalance())
|
||||
|
||||
// Get symbol stats
|
||||
symbolStats, _ := store.Position().GetSymbolStats(trader.GetID(), 10)
|
||||
symbolStats, _ := store.Position().GetSymbolStatsByTraderFilters(traderIDs, traderIDPatterns, 10)
|
||||
|
||||
// Get direction stats
|
||||
directionStats, _ := store.Position().GetDirectionStats(trader.GetID())
|
||||
directionStats, _ := store.Position().GetDirectionStatsByTraderFilters(traderIDs, traderIDPatterns)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"positions": positions,
|
||||
@@ -357,8 +380,8 @@ func (s *Server) handleOrderFills(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get fills for this order
|
||||
fills, err := store.Order().GetOrderFills(orderID)
|
||||
// Get fills for this order, scoped to the trader (ownership boundary).
|
||||
fills, err := store.Order().GetOrderFills(traderID, orderID)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get order fills", err)
|
||||
return
|
||||
|
||||
58
api/handler_stats_full.go
Normal file
58
api/handler_stats_full.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// handleStatisticsFull returns the full set of computed performance metrics for
|
||||
// a single trader: win rate, profit factor, Sharpe ratio, max drawdown, and the
|
||||
// average win/loss amounts. These are derived from the trader's CLOSED positions
|
||||
// via store.Position().GetFullStatsByTraderFilters — the same computation the
|
||||
// strategy engine feeds to the AI, so the dashboard and the model see identical
|
||||
// numbers.
|
||||
//
|
||||
// The existing GET /statistics endpoint only returns cycle/position counts; this
|
||||
// endpoint exposes the richer trade-quality metrics the terminal dashboard needs.
|
||||
func (s *Server) handleStatisticsFull(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
store := trader.GetStore()
|
||||
if store == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Aggregate across the trader's historical IDs exactly like the position
|
||||
// history endpoint (handler_order.go). One-click "NOFX Autopilot" relaunches
|
||||
// create fresh trader rows, but the closed positions stay under the old
|
||||
// generated IDs (which embed userID + "claw402"). Without this, a freshly
|
||||
// relaunched Autopilot would report only the current incarnation's trades
|
||||
// instead of its real lifetime history.
|
||||
userID := c.GetString("user_id")
|
||||
traderIDs := []string{trader.GetID()}
|
||||
var traderIDPatterns []string
|
||||
if strings.EqualFold(strings.TrimSpace(trader.GetName()), "NOFX Autopilot") && strings.TrimSpace(userID) != "" {
|
||||
traderIDPatterns = append(traderIDPatterns, "%_"+userID+"_claw402_%")
|
||||
}
|
||||
|
||||
stats, err := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns, trader.GetInitialBalance())
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get full statistics", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
@@ -14,6 +14,11 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
maxManualBTCETHLeverage = 20
|
||||
maxManualAltLeverage = 20
|
||||
)
|
||||
|
||||
// AI trader management related structures
|
||||
type CreateTraderRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
@@ -56,26 +61,44 @@ type UpdateTraderRequest struct {
|
||||
|
||||
func formatTraderCreationError(reason, nextStep string) string {
|
||||
if nextStep == "" {
|
||||
return fmt.Sprintf("这次未能创建机器人:%s。", reason)
|
||||
return fmt.Sprintf("Failed to create the bot this time: %s.", reason)
|
||||
}
|
||||
return fmt.Sprintf("这次未能创建机器人:%s。%s。", reason, nextStep)
|
||||
return fmt.Sprintf("Failed to create the bot this time: %s. %s.", reason, nextStep)
|
||||
}
|
||||
|
||||
func traderCreationRequestError(reason string) string {
|
||||
return formatTraderCreationError(reason, "请检查你刚刚填写的内容后,再重新提交")
|
||||
return formatTraderCreationError(reason, "Please check the information you just entered and submit again")
|
||||
}
|
||||
|
||||
func validateTraderLeverageRange(btcEthLeverage, altcoinLeverage int) (string, string) {
|
||||
if btcEthLeverage < 0 || btcEthLeverage > maxManualBTCETHLeverage {
|
||||
return traderCreationRequestError("BTC/ETH leverage must be between 1x and 20x"), "trader.create.invalid_btc_eth_leverage"
|
||||
}
|
||||
if altcoinLeverage < 0 || altcoinLeverage > maxManualAltLeverage {
|
||||
return traderCreationRequestError("Altcoin leverage must be between 1x and 20x"), "trader.create.invalid_altcoin_leverage"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func isSupportedTraderSymbol(symbol string) bool {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(symbol))
|
||||
if normalized == "" {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(normalized, "USDT") || strings.HasSuffix(normalized, "-USDC") || strings.HasPrefix(normalized, "XYZ:")
|
||||
}
|
||||
|
||||
func exchangeDisplayName(exchange *store.Exchange) string {
|
||||
if exchange == nil {
|
||||
return "所选交易所账户"
|
||||
return "the selected exchange account"
|
||||
}
|
||||
if exchange.AccountName != "" {
|
||||
return fmt.Sprintf("%s(%s)", exchange.Name, exchange.AccountName)
|
||||
return fmt.Sprintf("%s (%s)", exchange.Name, exchange.AccountName)
|
||||
}
|
||||
if exchange.Name != "" {
|
||||
return exchange.Name
|
||||
}
|
||||
return "所选交易所账户"
|
||||
return "the selected exchange account"
|
||||
}
|
||||
|
||||
func missingExchangeFields(exchange *store.Exchange) []string {
|
||||
@@ -104,10 +127,10 @@ func missingExchangeFields(exchange *store.Exchange) []string {
|
||||
}
|
||||
case "hyperliquid":
|
||||
if exchange.APIKey == "" {
|
||||
missing = append(missing, "私钥")
|
||||
missing = append(missing, "Private Key")
|
||||
}
|
||||
if strings.TrimSpace(exchange.HyperliquidWalletAddr) == "" {
|
||||
missing = append(missing, "钱包地址")
|
||||
missing = append(missing, "Wallet Address")
|
||||
}
|
||||
case "aster":
|
||||
if strings.TrimSpace(exchange.AsterUser) == "" {
|
||||
@@ -121,7 +144,7 @@ func missingExchangeFields(exchange *store.Exchange) []string {
|
||||
}
|
||||
case "lighter":
|
||||
if strings.TrimSpace(exchange.LighterWalletAddr) == "" {
|
||||
missing = append(missing, "钱包地址")
|
||||
missing = append(missing, "Wallet Address")
|
||||
}
|
||||
if exchange.LighterAPIKeyPrivateKey == "" {
|
||||
missing = append(missing, "API Key Private Key")
|
||||
@@ -145,21 +168,21 @@ func mapStringPairs(kv ...string) map[string]string {
|
||||
|
||||
func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string, map[string]string) {
|
||||
if exchange == nil {
|
||||
return formatTraderCreationError("还没有找到你选择的交易所账户", "请前往「设置 > 交易所配置」先添加一个可用账户,再回来创建机器人"),
|
||||
return formatTraderCreationError("The exchange account you selected was not found", "Please go to \"Settings > Exchange Config\" and add an available account first, then come back to create the bot"),
|
||||
"trader.create.exchange_not_found", nil
|
||||
}
|
||||
if !exchange.Enabled {
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」目前处于未启用状态", exchangeDisplayName(exchange)),
|
||||
"请前往「设置 > 交易所配置」启用该账户后,再重新创建机器人",
|
||||
fmt.Sprintf("Exchange account \"%s\" is currently disabled", exchangeDisplayName(exchange)),
|
||||
"Please go to \"Settings > Exchange Config\" to enable this account, then create the bot again",
|
||||
), "trader.create.exchange_disabled", mapStringPairs("exchange_name", exchangeDisplayName(exchange))
|
||||
}
|
||||
|
||||
missing := missingExchangeFields(exchange)
|
||||
if len(missing) > 0 {
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」的配置还不完整,缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")),
|
||||
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
|
||||
fmt.Sprintf("The configuration for exchange account \"%s\" is incomplete, missing %s", exchangeDisplayName(exchange), strings.Join(missing, ", ")),
|
||||
"Please go to \"Settings > Exchange Config\" to complete the required information for this account, then create the bot again",
|
||||
), "trader.create.exchange_missing_fields", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"missing_fields", strings.Join(missing, ", "),
|
||||
@@ -171,8 +194,8 @@ func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string
|
||||
return "", "", nil
|
||||
default:
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
|
||||
"请改用当前版本支持的交易所账户后,再重新创建机器人",
|
||||
fmt.Sprintf("Exchange account \"%s\" uses type %s, which is not supported in the current version", exchangeDisplayName(exchange), exchange.ExchangeType),
|
||||
"Please switch to an exchange account supported by the current version, then create the bot again",
|
||||
), "trader.create.exchange_unsupported", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"exchange_type", exchange.ExchangeType,
|
||||
@@ -191,28 +214,28 @@ func classifyTraderSetupReason(reason string) (string, string) {
|
||||
switch {
|
||||
case strings.Contains(lower, "failed to parse strategy config"),
|
||||
strings.Contains(lower, "failed to parse strategy configuration"):
|
||||
return "trader.reason.strategy_config_invalid", "当前策略配置内容已损坏,系统暂时无法解析"
|
||||
return "trader.reason.strategy_config_invalid", "The current strategy configuration is corrupted and the system cannot parse it for now"
|
||||
case strings.Contains(lower, "has no strategy configured"):
|
||||
return "trader.reason.strategy_missing", "当前机器人缺少有效的交易策略配置"
|
||||
return "trader.reason.strategy_missing", "The current bot is missing a valid trading strategy configuration"
|
||||
case strings.Contains(lower, "failed to parse private key"),
|
||||
(strings.Contains(lower, "invalid hex character") && strings.Contains(lower, "private key")):
|
||||
return "trader.reason.private_key_invalid", "私钥格式不正确,系统无法识别"
|
||||
return "trader.reason.private_key_invalid", "The private key format is incorrect and the system cannot recognize it"
|
||||
case strings.Contains(lower, "failed to initialize hyperliquid trader"):
|
||||
return "trader.reason.hyperliquid_init_failed", "Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确"
|
||||
return "trader.reason.hyperliquid_init_failed", "Hyperliquid account initialization failed; please confirm the private key, main wallet address, and Agent Wallet configuration are correct"
|
||||
case strings.Contains(lower, "failed to initialize aster trader"):
|
||||
return "trader.reason.aster_init_failed", "Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确"
|
||||
return "trader.reason.aster_init_failed", "Aster account initialization failed; please confirm the Aster User, Signer, and private key are correct"
|
||||
case strings.Contains(lower, "failed to get meta information"):
|
||||
return "trader.reason.exchange_meta_unavailable", "系统暂时无法从交易所读取账户元信息"
|
||||
return "trader.reason.exchange_meta_unavailable", "The system cannot read account meta information from the exchange for now"
|
||||
case strings.Contains(lower, "security check failed") && strings.Contains(lower, "agent wallet balance too high"):
|
||||
return "trader.reason.hyperliquid_agent_balance_too_high", "Hyperliquid Agent Wallet 余额过高,不符合当前安全要求"
|
||||
return "trader.reason.hyperliquid_agent_balance_too_high", "The Hyperliquid Agent Wallet balance is too high and does not meet the current security requirements"
|
||||
case strings.Contains(lower, "failed to initialize account"):
|
||||
return "trader.reason.exchange_account_init_failed", "交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配"
|
||||
return "trader.reason.exchange_account_init_failed", "Exchange account initialization failed; please confirm the wallet address and API Key match"
|
||||
case strings.Contains(lower, "unsupported trading platform"):
|
||||
return "trader.reason.exchange_unsupported", "当前交易所类型暂不支持机器人初始化"
|
||||
return "trader.reason.exchange_unsupported", "The current exchange type does not support bot initialization"
|
||||
case strings.Contains(lower, "initial balance not set and unable to fetch balance from exchange"):
|
||||
return "trader.reason.exchange_balance_unavailable", "系统暂时无法从交易所读取账户余额"
|
||||
return "trader.reason.exchange_balance_unavailable", "The system cannot read the account balance from the exchange for now"
|
||||
case strings.Contains(lower, "timeout"), strings.Contains(lower, "no such host"), strings.Contains(lower, "connection refused"):
|
||||
return "trader.reason.exchange_service_unreachable", "系统暂时无法连接交易所服务"
|
||||
return "trader.reason.exchange_service_unreachable", "The system cannot connect to the exchange service for now"
|
||||
default:
|
||||
return "trader.reason.unknown", trimmed
|
||||
}
|
||||
@@ -247,54 +270,54 @@ func traderSetupReasonParams(err error, fallback string, kv ...string) map[strin
|
||||
|
||||
func describeTraderLoadError(traderName string, err error) string {
|
||||
if err == nil {
|
||||
return formatTraderCreationError("机器人配置虽然保存了,但运行实例没有成功初始化", "请检查模型、策略和交易所配置是否完整,然后再试一次")
|
||||
return formatTraderCreationError("The bot configuration was saved, but the runtime instance failed to initialize", "Please check that the model, strategy, and exchange configuration are complete, then try again")
|
||||
}
|
||||
|
||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
||||
if reason == "" {
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动", traderName),
|
||||
"请检查模型、策略和交易所配置是否完整,然后再试一次",
|
||||
fmt.Sprintf("Bot \"%s\" failed to start when initializing its runtime instance", traderName),
|
||||
"Please check that the model, strategy, and exchange configuration are complete, then try again",
|
||||
)
|
||||
}
|
||||
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动,原因是:%s", traderName, reason),
|
||||
"请检查模型、策略和交易所配置是否完整,然后再试一次",
|
||||
fmt.Sprintf("Bot \"%s\" failed to start when initializing its runtime instance, because: %s", traderName, reason),
|
||||
"Please check that the model, strategy, and exchange configuration are complete, then try again",
|
||||
)
|
||||
}
|
||||
|
||||
func describeTraderCreationWarning(traderName string, err error) string {
|
||||
if err == nil {
|
||||
return fmt.Sprintf("机器人「%s」已经保存,但当前还没有通过启动前校验。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName)
|
||||
return fmt.Sprintf("Bot \"%s\" has been saved, but it has not yet passed the pre-start validation. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName)
|
||||
}
|
||||
|
||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
||||
if reason == "" {
|
||||
return fmt.Sprintf("机器人「%s」已经保存,但当前暂时还不能启动。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName)
|
||||
return fmt.Sprintf("Bot \"%s\" has been saved, but it cannot start for now. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("机器人「%s」已经保存,但当前暂时还不能启动,原因是:%s。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName, reason)
|
||||
return fmt.Sprintf("Bot \"%s\" has been saved, but it cannot start for now, because: %s. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName, reason)
|
||||
}
|
||||
|
||||
func describeTraderStartError(traderName string, err error) string {
|
||||
if err == nil {
|
||||
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后,再重新点击启动。", traderName)
|
||||
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now. Please check the model, strategy, and exchange configuration, then click start again.", traderName)
|
||||
}
|
||||
|
||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
||||
if reason == "" {
|
||||
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后,再重新点击启动。", traderName)
|
||||
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now. Please check the model, strategy, and exchange configuration, then click start again.", traderName)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动,原因是:%s。请检查模型、策略和交易所配置后,再重新点击启动。", traderName, reason)
|
||||
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now, because: %s. Please check the model, strategy, and exchange configuration, then click start again.", traderName, reason)
|
||||
}
|
||||
|
||||
func formatTraderStartError(reason, nextStep string) string {
|
||||
if nextStep == "" {
|
||||
return fmt.Sprintf("这次未能启动机器人:%s。", reason)
|
||||
return fmt.Sprintf("Failed to start the bot this time: %s.", reason)
|
||||
}
|
||||
return fmt.Sprintf("这次未能启动机器人:%s。%s。", reason, nextStep)
|
||||
return fmt.Sprintf("Failed to start the bot this time: %s. %s.", reason, nextStep)
|
||||
}
|
||||
|
||||
// handleCreateTrader Create new AI trader
|
||||
@@ -302,28 +325,26 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
var req CreateTraderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("提交的信息不完整,或者格式不正确"), "trader.create.invalid_request", nil)
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("The submitted information is incomplete or has an invalid format"), "trader.create.invalid_request", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate leverage values
|
||||
if req.BTCETHLeverage < 0 || req.BTCETHLeverage > 50 {
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 50 倍之间"), "trader.create.invalid_btc_eth_leverage", nil)
|
||||
return
|
||||
}
|
||||
if req.AltcoinLeverage < 0 || req.AltcoinLeverage > 20 {
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage", nil)
|
||||
// Validate leverage values against the same limits exposed by manual user config.
|
||||
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
|
||||
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate trading symbol format
|
||||
// Validate trading symbol format. Hyperliquid xyz dex markets (stocks,
|
||||
// commodities, indices, FX, Pre-IPO) are user-facing SYMBOL-USDC pairs,
|
||||
// while standard crypto/perp markets keep the legacy USDT suffix format.
|
||||
if req.TradingSymbols != "" {
|
||||
symbols := strings.Split(req.TradingSymbols, ",")
|
||||
for _, symbol := range symbols {
|
||||
symbol = strings.TrimSpace(symbol)
|
||||
if symbol != "" && !strings.HasSuffix(strings.ToUpper(symbol), "USDT") {
|
||||
if !isSupportedTraderSymbol(symbol) {
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError(
|
||||
fmt.Sprintf("交易对 %s 的格式不正确,目前只支持以 USDT 结尾的合约交易对", symbol),
|
||||
fmt.Sprintf("The trading pair %s has an invalid format; only USDT perpetuals or Hyperliquid XYZ USDC instruments (SYMBOL-USDC) are currently supported", symbol),
|
||||
), "trader.create.invalid_symbol", mapStringPairs("symbol", symbol))
|
||||
return
|
||||
}
|
||||
@@ -333,32 +354,32 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
model, err := s.store.AIModel().Get(userID, req.AIModelID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("还没有找到你选择的 AI 模型", "请前往「设置 > 模型配置」先添加并启用一个可用模型,再回来创建机器人"), "trader.create.model_not_found", nil)
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("The AI model you selected was not found", "Please go to \"Settings > Model Config\" to add and enable an available model first, then come back to create the bot"), "trader.create.model_not_found", nil)
|
||||
return
|
||||
}
|
||||
SafeError(c, http.StatusInternalServerError,
|
||||
formatTraderCreationError("暂时无法读取你的 AI 模型配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
|
||||
formatTraderCreationError("Unable to read your AI model configuration for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
if !model.Enabled {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
||||
fmt.Sprintf("AI 模型「%s」目前还没有启用", model.Name),
|
||||
"请前往「设置 > 模型配置」启用它后,再重新创建机器人",
|
||||
fmt.Sprintf("AI model \"%s\" is not enabled yet", model.Name),
|
||||
"Please go to \"Settings > Model Config\" to enable it, then create the bot again",
|
||||
), "trader.create.model_disabled", mapStringPairs("model_name", model.Name))
|
||||
return
|
||||
}
|
||||
if model.APIKey == "" {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
||||
fmt.Sprintf("AI 模型「%s」缺少 API Key 或支付凭证", model.Name),
|
||||
"请前往「设置 > 模型配置」补全模型凭证后,再重新创建机器人",
|
||||
fmt.Sprintf("AI model \"%s\" is missing an API Key or payment credentials", model.Name),
|
||||
"Please go to \"Settings > Model Config\" to complete the model credentials, then create the bot again",
|
||||
), "trader.create.model_missing_credentials", mapStringPairs("model_name", model.Name))
|
||||
return
|
||||
}
|
||||
|
||||
if req.StrategyID == "" {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("你还没有选择交易策略", "请先选择一个策略,再继续创建机器人"), "trader.create.strategy_required", nil)
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("You have not selected a trading strategy yet", "Please select a strategy first, then continue creating the bot"), "trader.create.strategy_required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -366,11 +387,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
_, err = s.store.Strategy().Get(userID, req.StrategyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("你选择的策略不存在,或者已经被删除了", "请重新选择一个可用策略后,再继续创建机器人"), "trader.create.strategy_not_found", nil)
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("The strategy you selected does not exist or has been deleted", "Please select another available strategy, then continue creating the bot"), "trader.create.strategy_not_found", nil)
|
||||
return
|
||||
}
|
||||
SafeError(c, http.StatusInternalServerError,
|
||||
formatTraderCreationError("暂时无法读取你选择的策略配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
|
||||
formatTraderCreationError("Unable to read the strategy configuration you selected for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
|
||||
err,
|
||||
)
|
||||
return
|
||||
@@ -413,8 +434,10 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
|
||||
// Set scan interval default value
|
||||
scanIntervalMinutes := req.ScanIntervalMinutes
|
||||
if scanIntervalMinutes < 3 {
|
||||
scanIntervalMinutes = 3 // Default 3 minutes, not allowed to be less than 3
|
||||
if scanIntervalMinutes <= 0 {
|
||||
scanIntervalMinutes = 15
|
||||
} else if scanIntervalMinutes < 3 {
|
||||
scanIntervalMinutes = 3 // Explicit values below 3 minutes are clamped to the minimum.
|
||||
}
|
||||
|
||||
// Query exchange actual balance, override user input
|
||||
@@ -422,7 +445,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
exchanges, err := s.store.Exchange().List(userID)
|
||||
if err != nil {
|
||||
SafeError(c, http.StatusInternalServerError,
|
||||
formatTraderCreationError("暂时无法读取你的交易所配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
|
||||
formatTraderCreationError("Unable to read your exchange configuration for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
|
||||
err,
|
||||
)
|
||||
return
|
||||
@@ -446,9 +469,9 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
|
||||
if createErr != nil {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」没有通过初始化校验,原因是:%s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "配置校验未通过"))),
|
||||
"请前往「设置 > 交易所配置」检查这个账户的密钥、地址和账户信息是否填写正确",
|
||||
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "配置校验未通过",
|
||||
fmt.Sprintf("Exchange account \"%s\" did not pass initialization validation, because: %s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "Configuration validation failed"))),
|
||||
"Please go to \"Settings > Exchange Config\" to check whether this account's keys, address, and account information are entered correctly",
|
||||
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "Configuration validation failed",
|
||||
"exchange_name", exchangeDisplayName(exchangeCfg),
|
||||
))
|
||||
return
|
||||
@@ -498,9 +521,9 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
err = s.store.Trader().Create(traderRecord)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to create trader: %v", err)
|
||||
publicMsg := SanitizeError(err, formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次"))
|
||||
publicMsg := SanitizeError(err, formatTraderCreationError("The bot configuration was not saved successfully", "Please check the name, model, strategy, and exchange configuration, then try again"))
|
||||
statusCode := http.StatusBadRequest
|
||||
if publicMsg == formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次") {
|
||||
if publicMsg == formatTraderCreationError("The bot configuration was not saved successfully", "Please check the name, model, strategy, and exchange configuration, then try again") {
|
||||
statusCode = http.StatusInternalServerError
|
||||
}
|
||||
SafeError(c, statusCode, publicMsg, err)
|
||||
@@ -574,6 +597,11 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
|
||||
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Set default values
|
||||
isCrossMargin := existingTrader.IsCrossMargin // Keep original value
|
||||
if req.IsCrossMargin != nil {
|
||||
@@ -703,6 +731,9 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
"trader_id": traderID,
|
||||
"trader_name": req.Name,
|
||||
"ai_model": req.AIModelID,
|
||||
// A running trader is restarted with the new config (async above), so
|
||||
// report it as running — callers must not fire a redundant start.
|
||||
"is_running": wasRunning,
|
||||
"message": "Trader updated successfully",
|
||||
})
|
||||
}
|
||||
@@ -751,12 +782,23 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
traderName = fullCfg.Trader.Name
|
||||
}
|
||||
|
||||
if fullCfg != nil && fullCfg.Exchange != nil && fullCfg.Exchange.ExchangeType == "hyperliquid" && !fullCfg.Exchange.HyperliquidBuilderApproved {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError(
|
||||
fmt.Sprintf("The Hyperliquid trading authorization for bot \"%s\" is not yet complete", traderName),
|
||||
"Please reconnect the Hyperliquid wallet and complete the trading authorization, then start the bot",
|
||||
), "trader.start.hyperliquid_builder_not_approved", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if trader exists in memory and if it's running
|
||||
existingTrader, _ := s.traderManager.GetTrader(traderID)
|
||||
if existingTrader != nil {
|
||||
status := existingTrader.GetStatus()
|
||||
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader is already running"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Trader is already running",
|
||||
"error_key": "trader.start.already_running",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Trader exists but is stopped - remove from memory to reload fresh config
|
||||
@@ -782,25 +824,25 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
}
|
||||
// Check AI model
|
||||
if fullCfg.AIModel == nil {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的 AI 模型不存在", "请前往「设置 > 模型配置」检查后,再重新点击启动"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError("The AI model associated with this bot does not exist", "Please go to \"Settings > Model Config\" to check, then click start again"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
|
||||
return
|
||||
}
|
||||
if !fullCfg.AIModel.Enabled {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError(
|
||||
fmt.Sprintf("机器人「%s」关联的 AI 模型「%s」目前还没有启用", traderName, fullCfg.AIModel.Name),
|
||||
"请前往「设置 > 模型配置」启用它后,再重新点击启动",
|
||||
fmt.Sprintf("The AI model \"%s\" associated with bot \"%s\" is not enabled yet", fullCfg.AIModel.Name, traderName),
|
||||
"Please go to \"Settings > Model Config\" to enable it, then click start again",
|
||||
), "trader.start.model_disabled", mapStringPairs("trader_name", traderName, "model_name", fullCfg.AIModel.Name))
|
||||
return
|
||||
}
|
||||
// Check exchange
|
||||
if fullCfg.Exchange == nil {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的交易所账户不存在", "请前往「设置 > 交易所配置」检查后,再重新点击启动"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError("The exchange account associated with this bot does not exist", "Please go to \"Settings > Exchange Config\" to check, then click start again"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
|
||||
return
|
||||
}
|
||||
if !fullCfg.Exchange.Enabled {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError(
|
||||
fmt.Sprintf("机器人「%s」关联的交易所账户「%s」目前还没有启用", traderName, exchangeDisplayName(fullCfg.Exchange)),
|
||||
"请前往「设置 > 交易所配置」启用它后,再重新点击启动",
|
||||
fmt.Sprintf("The exchange account \"%s\" associated with bot \"%s\" is not enabled yet", exchangeDisplayName(fullCfg.Exchange), traderName),
|
||||
"Please go to \"Settings > Exchange Config\" to enable it, then click start again",
|
||||
), "trader.start.exchange_disabled", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
|
||||
return
|
||||
}
|
||||
@@ -814,6 +856,24 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Server-side launch gate: the trader cannot function without a funded AI
|
||||
// wallet and a ready exchange account, so verify both before the run loop
|
||||
// starts. `?force=true` skips the gate for deliberate manual overrides.
|
||||
if c.Query("force") != "true" {
|
||||
// strategyRequired=false: a trader that loaded into memory necessarily
|
||||
// has a valid strategy (the manager refuses to load without one), so the
|
||||
// preflight strategy check would be redundant here.
|
||||
preflight := s.runLaunchPreflight(userID, fullCfg.AIModel, fullCfg.Exchange, fullCfg.Strategy, false)
|
||||
if !preflight.Ready {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": formatTraderStartError(preflight.Summary(), "Complete the failing checks, then start the bot again"),
|
||||
"error_key": "trader.start.preflight_failed",
|
||||
"preflight": preflight,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Start trader
|
||||
go func() {
|
||||
logger.Infof("▶️ Starting trader %s (%s)", traderID, trader.GetName())
|
||||
|
||||
@@ -267,8 +267,12 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
||||
|
||||
logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result)
|
||||
|
||||
// Record order to database (for chart markers and history)
|
||||
// Backfill the just-closed fill immediately. Manual closes may happen while
|
||||
// the bot runtime is stopped, so the background OrderSync loop is not enough.
|
||||
if syncErr := s.syncOrdersAfterManualClose(tempTrader, traderID, exchangeCfg.ID, exchangeCfg.ExchangeType); syncErr != nil {
|
||||
logger.Infof(" ⚠️ Manual close sync failed: %v", syncErr)
|
||||
s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Position closed successfully",
|
||||
@@ -278,6 +282,49 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) syncOrdersFromExchange(exchangeTrader trader.Trader, traderID, exchangeID, exchangeType string) error {
|
||||
switch t := exchangeTrader.(type) {
|
||||
case *binance.FuturesTrader:
|
||||
return t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, s.store)
|
||||
case *hyperliquidtrader.HyperliquidTrader:
|
||||
return t.SyncOrdersFromHyperliquid(traderID, exchangeID, exchangeType, s.store)
|
||||
case *aster.AsterTrader:
|
||||
return t.SyncOrdersFromAster(traderID, exchangeID, exchangeType, s.store)
|
||||
case *bybit.BybitTrader:
|
||||
return t.SyncOrdersFromBybit(traderID, exchangeID, exchangeType, s.store)
|
||||
case *okx.OKXTrader:
|
||||
return t.SyncOrdersFromOKX(traderID, exchangeID, exchangeType, s.store)
|
||||
case *bitget.BitgetTrader:
|
||||
return t.SyncOrdersFromBitget(traderID, exchangeID, exchangeType, s.store)
|
||||
case *gate.GateTrader:
|
||||
return t.SyncOrdersFromGate(traderID, exchangeID, exchangeType, s.store)
|
||||
case *kucoin.KuCoinTrader:
|
||||
return t.SyncOrdersFromKuCoin(traderID, exchangeID, exchangeType, s.store)
|
||||
case *lighter.LighterTraderV2:
|
||||
return t.SyncOrdersFromLighter(traderID, exchangeID, exchangeType, s.store)
|
||||
default:
|
||||
return fmt.Errorf("order sync is not available for exchange type %s", exchangeType)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) syncOrdersAfterManualClose(exchangeTrader trader.Trader, traderID, exchangeID, exchangeType string) error {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= 4; attempt++ {
|
||||
if attempt > 1 {
|
||||
time.Sleep(time.Duration(attempt-1) * 500 * time.Millisecond)
|
||||
}
|
||||
if err := s.syncOrdersFromExchange(exchangeTrader, traderID, exchangeID, exchangeType); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return fmt.Errorf("manual close sync did not run")
|
||||
}
|
||||
|
||||
// recordClosePositionOrder Record close position order to database (Lighter version - direct FILLED status)
|
||||
func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) {
|
||||
// Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates
|
||||
|
||||
28
api/handler_trader_symbol_test.go
Normal file
28
api/handler_trader_symbol_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsSupportedTraderSymbol(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
want bool
|
||||
}{
|
||||
{name: "legacy USDT perp", symbol: "BTCUSDT", want: true},
|
||||
{name: "legacy USDT perp lowercase", symbol: "ethusdt", want: true},
|
||||
{name: "Hyperliquid xyz stock USDC pair", symbol: "SMSN-USDC", want: true},
|
||||
{name: "Hyperliquid xyz commodity USDC pair", symbol: "GOLD-USDC", want: true},
|
||||
{name: "legacy internal xyz prefix still accepted", symbol: "xyz:SMSN", want: true},
|
||||
{name: "empty slot ignored", symbol: " ", want: true},
|
||||
{name: "bare stock without xyz prefix rejected", symbol: "SMSN", want: false},
|
||||
{name: "unknown non-USDT pair rejected", symbol: "BTCUSD", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isSupportedTraderSymbol(tt.symbol); got != tt.want {
|
||||
t.Fatalf("isSupportedTraderSymbol(%q) = %v, want %v", tt.symbol, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
17
api/handler_trader_test.go
Normal file
17
api/handler_trader_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateTraderLeverageRangeMatchesManualLimits(t *testing.T) {
|
||||
if msg, code := validateTraderLeverageRange(20, 20); msg != "" || code != "" {
|
||||
t.Fatalf("expected 20/20 leverage to be accepted, got msg=%q code=%q", msg, code)
|
||||
}
|
||||
|
||||
if msg, code := validateTraderLeverageRange(21, 20); msg == "" || code != "trader.create.invalid_btc_eth_leverage" {
|
||||
t.Fatalf("expected BTC/ETH leverage > 20 to be rejected, got msg=%q code=%q", msg, code)
|
||||
}
|
||||
|
||||
if msg, code := validateTraderLeverageRange(20, 21); msg == "" || code != "trader.create.invalid_altcoin_leverage" {
|
||||
t.Fatalf("expected altcoin leverage > 20 to be rejected, got msg=%q code=%q", msg, code)
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
|
||||
@@ -102,9 +102,11 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Adopt orphan records from previous account (e.g. after account reset)
|
||||
// This preserves wallet keys and exchange configs so funds are not lost.
|
||||
s.adoptOrphanRecords(userID)
|
||||
// NOTE: Orphan record adoption was removed for security reasons. Previously,
|
||||
// after a reset-account call, any new user would inherit the prior owner's
|
||||
// wallet keys and exchange API credentials — a catastrophic IDOR/takeover
|
||||
// path. Operators who need to migrate credentials across users must do so
|
||||
// explicitly via export/import, never via implicit adoption on registration.
|
||||
|
||||
// Generate JWT token
|
||||
token, err := auth.GenerateJWT(user.ID, user.Email)
|
||||
@@ -127,6 +129,13 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// dummyPasswordHash is a valid bcrypt hash of a throwaway value. It is compared
|
||||
// against when the submitted email does not exist so that login takes roughly
|
||||
// the same time whether or not the account exists — closing the timing side
|
||||
// channel that would otherwise let an attacker enumerate valid emails (a fast
|
||||
// "no such user" vs. a slow bcrypt compare). It is not a secret.
|
||||
const dummyPasswordHash = "$2a$10$0iF0bCoQLJ6Ph1bF.MXwHOW.IMTxQjeEW.w38dctRQAB2kwB6ga1q"
|
||||
|
||||
// handleLogin Handle user login request
|
||||
func (s *Server) handleLogin(c *gin.Context) {
|
||||
var req struct {
|
||||
@@ -142,6 +151,9 @@ func (s *Server) handleLogin(c *gin.Context) {
|
||||
// Get user information
|
||||
user, err := s.store.User().GetByEmail(req.Email)
|
||||
if err != nil {
|
||||
// Perform a dummy comparison so the response time does not reveal
|
||||
// whether the email exists (anti user-enumeration), then fail uniformly.
|
||||
auth.CheckPassword(req.Password, dummyPasswordHash)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
|
||||
return
|
||||
}
|
||||
@@ -189,86 +201,14 @@ func (s *Server) handleChangePassword(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
|
||||
}
|
||||
|
||||
// handleResetPassword Reset password via email and new password
|
||||
func (s *Server) handleResetPassword(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Query user
|
||||
user, err := s.store.User().GetByEmail(req.Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Email does not exist"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new password hash
|
||||
newPasswordHash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password processing failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update password
|
||||
err = s.store.User().UpdatePassword(user.ID, newPasswordHash)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password update failed"})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("✓ User %s password has been reset", user.Email)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password reset successful, please login with new password"})
|
||||
}
|
||||
|
||||
// handleResetAccount clears user authentication data so the system returns to
|
||||
// uninitialized state for re-registration. Wallet keys (ai_models) are preserved
|
||||
// so funds are not lost — they will be adopted by the new account during onboarding.
|
||||
func (s *Server) handleResetAccount(c *gin.Context) {
|
||||
err := s.store.Transaction(func(tx *gorm.DB) error {
|
||||
// Delete traders and strategies (config, not funds)
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{})
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{})
|
||||
// Delete users — ai_models and exchanges are intentionally kept
|
||||
// so wallet private keys and exchange configs survive re-registration
|
||||
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete users: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to reset account", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("✓ User accounts cleared (wallets preserved) — system reset to uninitialized")
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Account reset successful, you can now register a new account"})
|
||||
}
|
||||
|
||||
// adoptOrphanRecords re-assigns ai_models and exchanges whose user_id no longer
|
||||
// exists in the users table. This happens after account reset so the new user
|
||||
// inherits the previous wallet keys and exchange configurations.
|
||||
func (s *Server) adoptOrphanRecords(newUserID string) {
|
||||
db := s.store.GormDB()
|
||||
result := db.Model(&store.AIModel{}).
|
||||
Where("user_id NOT IN (SELECT id FROM users)").
|
||||
Update("user_id", newUserID)
|
||||
if result.RowsAffected > 0 {
|
||||
logger.Infof("✓ Adopted %d orphan ai_model(s) for new user %s", result.RowsAffected, newUserID)
|
||||
}
|
||||
|
||||
result = db.Model(&store.Exchange{}).
|
||||
Where("user_id NOT IN (SELECT id FROM users)").
|
||||
Update("user_id", newUserID)
|
||||
if result.RowsAffected > 0 {
|
||||
logger.Infof("✓ Adopted %d orphan exchange(s) for new user %s", result.RowsAffected, newUserID)
|
||||
}
|
||||
}
|
||||
// NOTE: Password and account recovery used to live here as the public,
|
||||
// unauthenticated handlers handleResetPassword / handleResetAccount. They were
|
||||
// removed because an unauthenticated recovery endpoint is a remotely
|
||||
// exploitable auth-bypass on any public-facing deployment: the confirm phrase
|
||||
// was embedded in the frontend (and echoed back by the API), so it was friction
|
||||
// rather than authentication. Recovery now lives in the local CLI
|
||||
// (`nofx reset-password` / `nofx reset-account`, see cli.go), which requires
|
||||
// shell access to the host — something a remote attacker does not have.
|
||||
|
||||
// initUserDefaultConfigs Initialize default configs for new user
|
||||
func (s *Server) initUserDefaultConfigs(userID string, lang string) error {
|
||||
@@ -285,23 +225,17 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
name, description string
|
||||
}
|
||||
type strategyLocale struct {
|
||||
balanced, conservative, aggressive strategyI18n
|
||||
defaultStrategy strategyI18n
|
||||
}
|
||||
locales := map[string]strategyLocale{
|
||||
"zh": {
|
||||
balanced: strategyI18n{"均衡策略", "系统默认策略。均衡风险收益,适合大多数市场环境。5倍杠杆,最多3个仓位。"},
|
||||
conservative: strategyI18n{"稳健策略", "系统默认策略。低杠杆保守操作,优先保护本金。3倍杠杆,专注主流资产。"},
|
||||
aggressive: strategyI18n{"积极策略", "系统默认策略。高杠杆主动交易,更广泛的币种选择,适合经验丰富的交易者。10倍杠杆,最多5个仓位。"},
|
||||
defaultStrategy: strategyI18n{"NOFX Claw402 Auto Strategy", "The only built-in strategy: read the Claw402.ai board each cycle, fetch Signal Lab and cost/liquidation heatmap per candidate, then decide with raw candles."},
|
||||
},
|
||||
"en": {
|
||||
balanced: strategyI18n{"Balanced Strategy", "System default strategy. Balanced risk-reward, suitable for most market conditions. 5x leverage, up to 3 positions."},
|
||||
conservative: strategyI18n{"Conservative Strategy", "System default strategy. Low-leverage conservative trading, capital preservation first. 3x leverage, focused on major assets."},
|
||||
aggressive: strategyI18n{"Aggressive Strategy", "System default strategy. High-leverage active trading, wider asset selection, for experienced traders. 10x leverage, up to 5 positions."},
|
||||
defaultStrategy: strategyI18n{"NOFX Claw402 Auto Strategy", "The only built-in strategy: read the Claw402.ai board each cycle, fetch Signal Lab and cost/liquidation heatmap per candidate, then decide with raw candles."},
|
||||
},
|
||||
"id": {
|
||||
balanced: strategyI18n{"Strategi Seimbang", "Strategi default sistem. Risiko-reward seimbang, cocok untuk sebagian besar kondisi pasar. Leverage 5x, hingga 3 posisi."},
|
||||
conservative: strategyI18n{"Strategi Konservatif", "Strategi default sistem. Trading konservatif leverage rendah, utamakan perlindungan modal. Leverage 3x, fokus aset utama."},
|
||||
aggressive: strategyI18n{"Strategi Agresif", "Strategi default sistem. Trading aktif leverage tinggi, pilihan aset lebih luas, untuk trader berpengalaman. Leverage 10x, hingga 5 posisi."},
|
||||
defaultStrategy: strategyI18n{"Strategi Otomatis NOFX Claw402", "Satu strategi bawaan: membaca papan Claw402.ai, mengambil Signal Lab dan heatmap biaya/likuidasi per kandidat, lalu memutuskan dengan candle mentah."},
|
||||
},
|
||||
}
|
||||
locale, ok := locales[lang]
|
||||
@@ -316,45 +250,44 @@ 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
|
||||
// 4× equity notional per position: at 10x leverage two full positions
|
||||
// use ~80% of margin — concentrated but solvent.
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = 4.0
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 4.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 +303,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 +319,49 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
strategies = append(strategies, strategy)
|
||||
}
|
||||
|
||||
legacyDefaultNames := []string{
|
||||
"Balanced Strategy", "Steady Strategy", "Aggressive Strategy",
|
||||
"US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy",
|
||||
"Balanced Strategy", "Conservative Strategy", "Aggressive Strategy",
|
||||
"US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy",
|
||||
"Strategi Seimbang", "Strategi Konservatif", "Strategi Agresif",
|
||||
"Strategi Tren Saham AS", "Strategi Stabil Saham AS", "Strategi Breakout Saham AS",
|
||||
}
|
||||
|
||||
return s.store.Transaction(func(tx *gorm.DB) error {
|
||||
// Remove obsolete built-in risk-profile presets for this user. If a trader still
|
||||
// references one of them, keep it to avoid breaking an existing running setup.
|
||||
deleteResult := tx.Where("user_id = ? AND name IN ? AND id NOT IN (SELECT strategy_id FROM traders WHERE user_id = ? AND strategy_id IS NOT NULL)", userID, legacyDefaultNames, userID).
|
||||
Delete(&store.Strategy{})
|
||||
if deleteResult.Error != nil {
|
||||
return fmt.Errorf("failed to remove legacy default strategies: %w", deleteResult.Error)
|
||||
}
|
||||
if deleteResult.RowsAffected > 0 {
|
||||
logger.Infof(" ✓ Removed %d legacy default strategy preset(s)", deleteResult.RowsAffected)
|
||||
}
|
||||
|
||||
var activeCount int64
|
||||
if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND is_active = ?", userID, true).Count(&activeCount).Error; err != nil {
|
||||
return fmt.Errorf("failed to count active strategies: %w", err)
|
||||
}
|
||||
|
||||
for _, strategy := range strategies {
|
||||
var existing int64
|
||||
if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND name = ?", userID, strategy.Name).Count(&existing).Error; err != nil {
|
||||
return fmt.Errorf("failed to check strategy %q: %w", strategy.Name, err)
|
||||
}
|
||||
if existing > 0 {
|
||||
continue
|
||||
}
|
||||
if activeCount > 0 {
|
||||
strategy.IsActive = false
|
||||
}
|
||||
if err := tx.Create(strategy).Error; err != nil {
|
||||
return fmt.Errorf("failed to create strategy %q: %w", strategy.Name, err)
|
||||
}
|
||||
if strategy.IsActive {
|
||||
activeCount++
|
||||
}
|
||||
logger.Infof(" ✓ Created default strategy: %s (active=%v)", strategy.Name, strategy.IsActive)
|
||||
}
|
||||
return nil
|
||||
|
||||
136
api/handler_user_default_strategy_test.go
Normal file
136
api/handler_user_default_strategy_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
func TestCreateDefaultStrategiesUsesOneReadyToRunClaw402Preset(t *testing.T) {
|
||||
st, err := store.New(t.TempDir() + "/nofx.db")
|
||||
if err != nil {
|
||||
t.Fatalf("store.New failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
s := &Server{store: st}
|
||||
userID := "user-us-stock-presets"
|
||||
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
|
||||
t.Fatalf("createDefaultStrategies failed: %v", err)
|
||||
}
|
||||
|
||||
strategies, err := st.Strategy().List(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("List strategies failed: %v", err)
|
||||
}
|
||||
if len(strategies) != 1 {
|
||||
t.Fatalf("expected 1 default strategy, got %d", len(strategies))
|
||||
}
|
||||
|
||||
byName := map[string]*store.Strategy{}
|
||||
activeCount := 0
|
||||
for _, strategy := range strategies {
|
||||
byName[strategy.Name] = strategy
|
||||
if strategy.IsActive {
|
||||
activeCount++
|
||||
}
|
||||
if strategy.Name == "Balanced Strategy" || strategy.Name == "Steady Strategy" || strategy.Name == "Aggressive Strategy" {
|
||||
t.Fatalf("legacy crypto-style default strategy still present: %s", strategy.Name)
|
||||
}
|
||||
}
|
||||
if activeCount != 1 {
|
||||
t.Fatalf("expected exactly one active strategy, got %d", activeCount)
|
||||
}
|
||||
|
||||
defaultStrategy := byName["NOFX Claw402 Auto Strategy"]
|
||||
if defaultStrategy == nil || !defaultStrategy.IsActive {
|
||||
t.Fatalf("NOFX Claw402 Auto Strategy should exist and be active")
|
||||
}
|
||||
trendCfg, err := defaultStrategy.ParseConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("default ParseConfig failed: %v", err)
|
||||
}
|
||||
if trendCfg.CoinSource.SourceType != "vergex_signal" || trendCfg.CoinSource.VergexLimit != 10 || trendCfg.CoinSource.VergexMarketType != "all" {
|
||||
t.Fatalf("default strategy should use the Claw402/Vergex all-market signal ranking, got %+v", trendCfg.CoinSource)
|
||||
}
|
||||
if trendCfg.CoinSource.UseAI500 || trendCfg.RiskControl.MaxPositions > 2 {
|
||||
t.Fatalf("default strategy should be Claw402/Vergex native with 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 != 4 ||
|
||||
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 4 ||
|
||||
trendCfg.RiskControl.MaxMarginUsage != 1.0 {
|
||||
t.Fatalf("default strategy should size Claw402 opens at 4x equity notional (two positions ≈ 80%% margin at 10x), got risk=%+v", trendCfg.RiskControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDefaultStrategiesMigratesLegacyPresetsWithoutOverridingActiveCustom(t *testing.T) {
|
||||
st, err := store.New(t.TempDir() + "/nofx.db")
|
||||
if err != nil {
|
||||
t.Fatalf("store.New failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
userID := "user-existing-custom"
|
||||
legacyCfg := store.GetDefaultStrategyConfig("zh")
|
||||
legacy := &store.Strategy{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: "Balanced Strategy",
|
||||
Description: "legacy",
|
||||
IsActive: false,
|
||||
}
|
||||
if err := legacy.SetConfig(&legacyCfg); err != nil {
|
||||
t.Fatalf("legacy SetConfig failed: %v", err)
|
||||
}
|
||||
if err := st.Strategy().Create(legacy); err != nil {
|
||||
t.Fatalf("create legacy failed: %v", err)
|
||||
}
|
||||
|
||||
custom := &store.Strategy{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: "aa",
|
||||
Description: "user custom active strategy",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := custom.SetConfig(&legacyCfg); err != nil {
|
||||
t.Fatalf("custom SetConfig failed: %v", err)
|
||||
}
|
||||
if err := st.Strategy().Create(custom); err != nil {
|
||||
t.Fatalf("create custom failed: %v", err)
|
||||
}
|
||||
|
||||
s := &Server{store: st}
|
||||
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
|
||||
t.Fatalf("createDefaultStrategies failed: %v", err)
|
||||
}
|
||||
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
|
||||
t.Fatalf("second createDefaultStrategies should be idempotent: %v", err)
|
||||
}
|
||||
|
||||
strategies, err := st.Strategy().List(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("List strategies failed: %v", err)
|
||||
}
|
||||
byName := map[string]int{}
|
||||
activeNames := []string{}
|
||||
for _, strategy := range strategies {
|
||||
byName[strategy.Name]++
|
||||
if strategy.IsActive {
|
||||
activeNames = append(activeNames, strategy.Name)
|
||||
}
|
||||
}
|
||||
if byName["Balanced Strategy"] != 0 {
|
||||
t.Fatalf("legacy preset should be removed, got names=%+v", byName)
|
||||
}
|
||||
if byName["NOFX Claw402 Auto Strategy"] != 1 {
|
||||
t.Fatalf("expected exactly one NOFX Claw402 Auto Strategy, got names=%+v", byName)
|
||||
}
|
||||
if len(activeNames) != 1 || activeNames[0] != "aa" {
|
||||
t.Fatalf("existing active custom strategy should stay the only active one, got %+v", activeNames)
|
||||
}
|
||||
}
|
||||
137
api/handler_vergex.go
Normal file
137
api/handler_vergex.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"nofx/provider/vergex"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (s *Server) handleVergexSignalRanking(c *gin.Context) {
|
||||
client, ok := s.newVergexClientForRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := client.GetSignalRanking(context.Background(), vergex.Query{
|
||||
Chain: strings.TrimSpace(c.Query("chain")),
|
||||
LiqBand: strings.TrimSpace(c.Query("liqBand")),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warnf("Vergex signal-ranking failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
limit := parsePositiveInt(c.Query("limit"), vergex.MaxSignalRankingItems)
|
||||
marketType := strings.TrimSpace(c.Query("marketType"))
|
||||
items := vergex.FilterSignalRankingItems(data.Items, marketType, limit)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"raw": data.Raw,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleVergexSignalLab(c *gin.Context) {
|
||||
client, ok := s.newVergexClientForRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := client.GetSignalLab(context.Background(), vergex.Query{
|
||||
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
|
||||
Symbol: strings.TrimSpace(c.Query("symbol")),
|
||||
Chain: strings.TrimSpace(c.Query("chain")),
|
||||
LiqBand: strings.TrimSpace(c.Query("liqBand")),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warnf("Vergex signal-lab failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
||||
}
|
||||
|
||||
func (s *Server) handleVergexCostLiquidationHeatmap(c *gin.Context) {
|
||||
client, ok := s.newVergexClientForRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := client.GetCostLiquidationHeatmap(context.Background(), vergex.Query{
|
||||
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
|
||||
Symbol: strings.TrimSpace(c.Query("symbol")),
|
||||
Chain: strings.TrimSpace(c.Query("chain")),
|
||||
LiqBand: strings.TrimSpace(c.Query("liqBand")),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warnf("Vergex cost-liquidation-heatmap failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
||||
}
|
||||
|
||||
// handleVergexFlowMarkets proxies the Vergex net-flow market ranking (paid x402
|
||||
// endpoint) using the caller's claw402 wallet. The upstream JSON is passed
|
||||
// through verbatim: { data: { window, by, inflow: [{ symbol, netFlow,
|
||||
// buyNotional, sellNotional, trades, latestPrice }, ...] } }.
|
||||
func (s *Server) handleVergexFlowMarkets(c *gin.Context) {
|
||||
client, ok := s.newVergexClientForRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
chain := withDefault(strings.TrimSpace(c.Query("chain")), "mainnet")
|
||||
window := withDefault(strings.TrimSpace(c.Query("window")), "1h")
|
||||
limit := parsePositiveInt(c.Query("limit"), 25)
|
||||
|
||||
body, err := client.GetFlowMarkets(context.Background(), chain, window, limit)
|
||||
if err != nil {
|
||||
logger.Warnf("Vergex flow-markets failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
||||
}
|
||||
|
||||
func (s *Server) newVergexClientForRequest(c *gin.Context) (*vergex.Client, bool) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return nil, false
|
||||
}
|
||||
walletKey, err := s.resolveStrategyDataWalletKey(userID, c.Query("ai_model_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return nil, false
|
||||
}
|
||||
if walletKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "claw402 wallet is not configured"})
|
||||
return nil, false
|
||||
}
|
||||
client, err := vergex.NewClient("", walletKey, &logger.MCPLogger{})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return nil, false
|
||||
}
|
||||
return client, true
|
||||
}
|
||||
|
||||
func parsePositiveInt(raw string, fallback int) int {
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil || n <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func withDefault(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
375
api/launch_preflight.go
Normal file
375
api/launch_preflight.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/store"
|
||||
"nofx/wallet"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Launch readiness minimums. These are the single source of truth — the
|
||||
// frontend reads them from the preflight response instead of hardcoding.
|
||||
const (
|
||||
// MinAIFeeUSDC is the minimum Base USDC a claw402 fee wallet needs so the
|
||||
// trader can pay for its first AI/data calls.
|
||||
MinAIFeeUSDC = 1.0
|
||||
// MinTradingUSDC is the minimum available balance the exchange account
|
||||
// needs before the trader can place its first order.
|
||||
MinTradingUSDC = 12.0
|
||||
)
|
||||
|
||||
const (
|
||||
launchCheckStatusOK = "ok"
|
||||
launchCheckStatusFailed = "failed"
|
||||
launchCheckStatusWarning = "warning"
|
||||
launchCheckStatusSkipped = "skipped"
|
||||
)
|
||||
|
||||
// Check IDs — stable identifiers the frontend maps to guided-setup steps.
|
||||
const (
|
||||
launchCheckAIModel = "ai_model"
|
||||
launchCheckAIWallet = "ai_wallet"
|
||||
launchCheckAIWalletFunds = "ai_wallet_funds"
|
||||
launchCheckStrategy = "strategy"
|
||||
launchCheckExchangeConfig = "exchange_config"
|
||||
launchCheckExchangeAccount = "exchange_account"
|
||||
launchCheckExchangeFunds = "exchange_funds"
|
||||
)
|
||||
|
||||
// queryAIWalletBalance is swappable in tests to avoid live Base RPC calls.
|
||||
var queryAIWalletBalance = wallet.QueryUSDCBalanceCached
|
||||
|
||||
type LaunchCheck struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
// Numeric context so the UI can render progress like "8.40 / 12 USDC".
|
||||
Required float64 `json:"required,omitempty"`
|
||||
Actual *float64 `json:"actual,omitempty"`
|
||||
Asset string `json:"asset,omitempty"`
|
||||
// Address is the funding address for balance checks, so the UI can offer
|
||||
// a deposit shortcut next to the failing item.
|
||||
Address string `json:"address,omitempty"`
|
||||
}
|
||||
|
||||
type LaunchPreflightResult struct {
|
||||
Ready bool `json:"ready"`
|
||||
Checks []LaunchCheck `json:"checks"`
|
||||
MinAIFeeUSDC float64 `json:"min_ai_fee_usdc"`
|
||||
MinTradingUSDC float64 `json:"min_trading_usdc"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
|
||||
func (r LaunchPreflightResult) failedChecks() []LaunchCheck {
|
||||
var failed []LaunchCheck
|
||||
for _, check := range r.Checks {
|
||||
if check.Status == launchCheckStatusFailed {
|
||||
failed = append(failed, check)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// Summary joins the failing messages into one human-readable sentence.
|
||||
func (r LaunchPreflightResult) Summary() string {
|
||||
failed := r.failedChecks()
|
||||
if len(failed) == 0 {
|
||||
return ""
|
||||
}
|
||||
messages := make([]string, 0, len(failed))
|
||||
for _, check := range failed {
|
||||
if check.Message != "" {
|
||||
messages = append(messages, check.Message)
|
||||
}
|
||||
}
|
||||
return strings.Join(messages, " ")
|
||||
}
|
||||
|
||||
type launchPreflightRequest struct {
|
||||
AIModelID string `json:"ai_model_id"`
|
||||
ExchangeID string `json:"exchange_id"`
|
||||
StrategyID string `json:"strategy_id"`
|
||||
}
|
||||
|
||||
// handleLaunchPreflight runs launch readiness checks for a model/exchange
|
||||
// pair before any trader is created or mutated. POST /api/launch/preflight
|
||||
func (s *Server) handleLaunchPreflight(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
var req launchPreflightRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid preflight request")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.AIModelID) == "" || strings.TrimSpace(req.ExchangeID) == "" {
|
||||
SafeBadRequest(c, "ai_model_id and exchange_id are required")
|
||||
return
|
||||
}
|
||||
|
||||
model, err := s.store.AIModel().Get(userID, req.AIModelID)
|
||||
if err != nil {
|
||||
model = nil
|
||||
}
|
||||
exchange, err := s.store.Exchange().GetByID(userID, req.ExchangeID)
|
||||
if err != nil {
|
||||
exchange = nil
|
||||
}
|
||||
|
||||
var strategy *store.Strategy
|
||||
strategyRequired := strings.TrimSpace(req.StrategyID) != ""
|
||||
if strategyRequired {
|
||||
strategy, err = s.store.Strategy().Get(userID, req.StrategyID)
|
||||
if err != nil {
|
||||
strategy = nil
|
||||
}
|
||||
}
|
||||
|
||||
result := s.runLaunchPreflight(userID, model, exchange, strategy, strategyRequired)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleTraderPreflight runs the same checks against an existing trader's
|
||||
// full configuration. GET /api/traders/:id/preflight
|
||||
func (s *Server) handleTraderPreflight(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
traderID := c.Param("id")
|
||||
|
||||
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
|
||||
if err != nil || fullCfg == nil || fullCfg.Trader == nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
result := s.runLaunchPreflight(userID, fullCfg.AIModel, fullCfg.Exchange, fullCfg.Strategy, true)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// runLaunchPreflight composes every launch readiness check. A check that
|
||||
// cannot be evaluated (RPC outage, probe timeout) reports "warning" instead
|
||||
// of "failed" so an infrastructure hiccup never hard-blocks a launch.
|
||||
func (s *Server) runLaunchPreflight(
|
||||
userID string,
|
||||
model *store.AIModel,
|
||||
exchange *store.Exchange,
|
||||
strategy *store.Strategy,
|
||||
strategyRequired bool,
|
||||
) LaunchPreflightResult {
|
||||
checks := []LaunchCheck{checkLaunchAIModel(model)}
|
||||
checks = append(checks, checkLaunchAIWallet(model)...)
|
||||
checks = append(checks, checkLaunchStrategy(strategy, strategyRequired))
|
||||
checks = append(checks, s.checkLaunchExchange(userID, exchange)...)
|
||||
|
||||
ready := true
|
||||
for _, check := range checks {
|
||||
if check.Status == launchCheckStatusFailed {
|
||||
ready = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return LaunchPreflightResult{
|
||||
Ready: ready,
|
||||
Checks: checks,
|
||||
MinAIFeeUSDC: MinAIFeeUSDC,
|
||||
MinTradingUSDC: MinTradingUSDC,
|
||||
CheckedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func checkLaunchAIModel(model *store.AIModel) LaunchCheck {
|
||||
check := LaunchCheck{ID: launchCheckAIModel}
|
||||
|
||||
switch {
|
||||
case model == nil:
|
||||
check.Status = launchCheckStatusFailed
|
||||
check.Code = "MODEL_NOT_FOUND"
|
||||
check.Message = "The selected AI model was not found. Configure an AI model first."
|
||||
case !model.Enabled:
|
||||
check.Status = launchCheckStatusFailed
|
||||
check.Code = "MODEL_DISABLED"
|
||||
check.Message = fmt.Sprintf("AI model \"%s\" is disabled. Enable it first.", model.Name)
|
||||
case strings.TrimSpace(model.APIKey.String()) == "":
|
||||
check.Status = launchCheckStatusFailed
|
||||
check.Code = "MODEL_MISSING_CREDENTIALS"
|
||||
check.Message = fmt.Sprintf("AI model \"%s\" has no credential saved. Add the API key or wallet key first.", model.Name)
|
||||
default:
|
||||
check.Status = launchCheckStatusOK
|
||||
check.Message = model.Name
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// checkLaunchAIWallet validates the claw402 fee wallet (address + Base USDC
|
||||
// balance). Non-claw402 providers pay per API key, so both checks are skipped.
|
||||
func checkLaunchAIWallet(model *store.AIModel) []LaunchCheck {
|
||||
walletCheck := LaunchCheck{ID: launchCheckAIWallet}
|
||||
fundsCheck := LaunchCheck{ID: launchCheckAIWalletFunds, Asset: "USDC", Required: MinAIFeeUSDC}
|
||||
|
||||
if model == nil || model.Provider != "claw402" || strings.TrimSpace(model.APIKey.String()) == "" {
|
||||
walletCheck.Status = launchCheckStatusSkipped
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{walletCheck, fundsCheck}
|
||||
}
|
||||
|
||||
address, err := walletAddressFromPrivateKey(model.APIKey.String())
|
||||
if err != nil {
|
||||
walletCheck.Status = launchCheckStatusFailed
|
||||
walletCheck.Code = "AI_WALLET_INVALID_KEY"
|
||||
walletCheck.Message = "The Claw402 wallet key is invalid. Recreate the Base USDC payment wallet."
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{walletCheck, fundsCheck}
|
||||
}
|
||||
|
||||
walletCheck.Status = launchCheckStatusOK
|
||||
walletCheck.Address = address
|
||||
fundsCheck.Address = address
|
||||
|
||||
balance, err := queryAIWalletBalance(address)
|
||||
if err != nil {
|
||||
fundsCheck.Status = launchCheckStatusWarning
|
||||
fundsCheck.Code = "AI_WALLET_BALANCE_UNKNOWN"
|
||||
fundsCheck.Message = "Could not verify the Base USDC balance right now. The trader will start, but AI calls fail if the wallet is empty."
|
||||
return []LaunchCheck{walletCheck, fundsCheck}
|
||||
}
|
||||
|
||||
fundsCheck.Actual = &balance
|
||||
if balance < MinAIFeeUSDC {
|
||||
fundsCheck.Status = launchCheckStatusFailed
|
||||
fundsCheck.Code = "AI_WALLET_INSUFFICIENT_FUNDS"
|
||||
fundsCheck.Message = fmt.Sprintf(
|
||||
"The Claw402 wallet holds %.2f USDC but needs at least %.0f USDC on Base to pay for AI and data calls.",
|
||||
balance, MinAIFeeUSDC,
|
||||
)
|
||||
} else {
|
||||
fundsCheck.Status = launchCheckStatusOK
|
||||
}
|
||||
|
||||
return []LaunchCheck{walletCheck, fundsCheck}
|
||||
}
|
||||
|
||||
func checkLaunchStrategy(strategy *store.Strategy, required bool) LaunchCheck {
|
||||
check := LaunchCheck{ID: launchCheckStrategy}
|
||||
|
||||
switch {
|
||||
case strategy != nil:
|
||||
check.Status = launchCheckStatusOK
|
||||
check.Message = strategy.Name
|
||||
case required:
|
||||
check.Status = launchCheckStatusFailed
|
||||
check.Code = "STRATEGY_NOT_FOUND"
|
||||
check.Message = "The selected strategy was not found. Pick or create a strategy first."
|
||||
default:
|
||||
check.Status = launchCheckStatusSkipped
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// checkLaunchExchange validates exchange configuration completeness and then
|
||||
// probes the live account (30s server cache) for status and balance.
|
||||
func (s *Server) checkLaunchExchange(userID string, exchange *store.Exchange) []LaunchCheck {
|
||||
configCheck := LaunchCheck{ID: launchCheckExchangeConfig}
|
||||
accountCheck := LaunchCheck{ID: launchCheckExchangeAccount}
|
||||
fundsCheck := LaunchCheck{ID: launchCheckExchangeFunds, Required: MinTradingUSDC}
|
||||
|
||||
if msg, code := describeExchangeConfigIssue(exchange); code != "" {
|
||||
configCheck.Status = launchCheckStatusFailed
|
||||
configCheck.Code = code
|
||||
configCheck.Message = msg
|
||||
accountCheck.Status = launchCheckStatusSkipped
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
configCheck.Status = launchCheckStatusOK
|
||||
configCheck.Message = exchangeDisplayName(exchange)
|
||||
fundsCheck.Asset = accountAssetForExchange(exchange.ExchangeType)
|
||||
|
||||
states, err := s.getExchangeAccountStates(userID)
|
||||
if err != nil {
|
||||
accountCheck.Status = launchCheckStatusWarning
|
||||
accountCheck.Code = "EXCHANGE_STATE_UNKNOWN"
|
||||
accountCheck.Message = "Could not verify the exchange account right now."
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
state, ok := states[exchange.ID]
|
||||
if !ok {
|
||||
accountCheck.Status = launchCheckStatusWarning
|
||||
accountCheck.Code = "EXCHANGE_STATE_UNKNOWN"
|
||||
accountCheck.Message = "Could not verify the exchange account right now."
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
if state.Status != exchangeAccountStatusOK {
|
||||
accountCheck.Status = launchCheckStatusFailed
|
||||
accountCheck.Code = state.ErrorCode
|
||||
accountCheck.Message = state.ErrorMessage
|
||||
if accountCheck.Message == "" {
|
||||
accountCheck.Message = fmt.Sprintf("Exchange account \"%s\" is not ready (%s).", exchangeDisplayName(exchange), state.Status)
|
||||
}
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
accountCheck.Status = launchCheckStatusOK
|
||||
accountCheck.Message = state.DisplayBalance
|
||||
|
||||
// Gate on the better of available balance and total equity: a bot with
|
||||
// capital deployed in open positions has low *available* margin but is
|
||||
// clearly funded — restarting it must not be blocked.
|
||||
funded := state.AvailableBalance
|
||||
if state.TotalEquity > funded {
|
||||
funded = state.TotalEquity
|
||||
}
|
||||
fundsCheck.Actual = &funded
|
||||
|
||||
if funded < MinTradingUSDC {
|
||||
message := fmt.Sprintf(
|
||||
"Exchange account \"%s\" holds %.2f %s but needs at least %.0f %s to place the first trade.",
|
||||
exchangeDisplayName(exchange), funded, fundsCheck.Asset, MinTradingUSDC, fundsCheck.Asset,
|
||||
)
|
||||
if exchange.Testnet {
|
||||
// Testnet balances are play money — warn instead of block.
|
||||
fundsCheck.Status = launchCheckStatusWarning
|
||||
} else {
|
||||
fundsCheck.Status = launchCheckStatusFailed
|
||||
}
|
||||
fundsCheck.Code = "EXCHANGE_INSUFFICIENT_FUNDS"
|
||||
fundsCheck.Message = message
|
||||
} else {
|
||||
fundsCheck.Status = launchCheckStatusOK
|
||||
}
|
||||
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
// describeExchangeConfigIssue mirrors the create-time exchange validation but
|
||||
// returns stable uppercase codes for the checklist UI.
|
||||
func describeExchangeConfigIssue(exchange *store.Exchange) (string, string) {
|
||||
if exchange == nil {
|
||||
return "The selected exchange account was not found. Connect an exchange first.", "EXCHANGE_NOT_FOUND"
|
||||
}
|
||||
if !exchange.Enabled {
|
||||
return fmt.Sprintf("Exchange account \"%s\" is disabled. Enable it first.", exchangeDisplayName(exchange)), "EXCHANGE_DISABLED"
|
||||
}
|
||||
if missing := missingExchangeFields(exchange); len(missing) > 0 {
|
||||
return fmt.Sprintf(
|
||||
"Exchange account \"%s\" is missing %s. Complete the connection first.",
|
||||
exchangeDisplayName(exchange), strings.Join(missing, ", "),
|
||||
), "EXCHANGE_MISSING_FIELDS"
|
||||
}
|
||||
if exchange.ExchangeType == "hyperliquid" && !exchange.HyperliquidBuilderApproved {
|
||||
return "Hyperliquid builder authorization is not complete. Reconnect the wallet and finish the authorization.", "HYPERLIQUID_BUILDER_NOT_APPROVED"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
293
api/launch_preflight_test.go
Normal file
293
api/launch_preflight_test.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"nofx/crypto"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
// Well-known throwaway development key (hardhat account #1) — never funded.
|
||||
const testClaw402Key = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"
|
||||
|
||||
func withAIWalletBalance(t *testing.T, balance float64, err error) {
|
||||
t.Helper()
|
||||
original := queryAIWalletBalance
|
||||
queryAIWalletBalance = func(string) (float64, error) {
|
||||
return balance, err
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
queryAIWalletBalance = original
|
||||
})
|
||||
}
|
||||
|
||||
func findCheck(t *testing.T, checks []LaunchCheck, id string) LaunchCheck {
|
||||
t.Helper()
|
||||
for _, check := range checks {
|
||||
if check.ID == id {
|
||||
return check
|
||||
}
|
||||
}
|
||||
t.Fatalf("check %q not found in %+v", id, checks)
|
||||
return LaunchCheck{}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIModel(t *testing.T) {
|
||||
if got := checkLaunchAIModel(nil); got.Status != launchCheckStatusFailed || got.Code != "MODEL_NOT_FOUND" {
|
||||
t.Fatalf("nil model: expected failed/MODEL_NOT_FOUND, got %+v", got)
|
||||
}
|
||||
|
||||
disabled := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: false}
|
||||
if got := checkLaunchAIModel(disabled); got.Code != "MODEL_DISABLED" {
|
||||
t.Fatalf("disabled model: expected MODEL_DISABLED, got %+v", got)
|
||||
}
|
||||
|
||||
noKey := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true}
|
||||
if got := checkLaunchAIModel(noKey); got.Code != "MODEL_MISSING_CREDENTIALS" {
|
||||
t.Fatalf("missing key: expected MODEL_MISSING_CREDENTIALS, got %+v", got)
|
||||
}
|
||||
|
||||
ready := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
if got := checkLaunchAIModel(ready); got.Status != launchCheckStatusOK {
|
||||
t.Fatalf("ready model: expected ok, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIWalletSkipsNonClaw402(t *testing.T) {
|
||||
model := &store.AIModel{Name: "DeepSeek", Provider: "deepseek", Enabled: true, APIKey: crypto.EncryptedString("sk-test")}
|
||||
checks := checkLaunchAIWallet(model)
|
||||
if got := findCheck(t, checks, launchCheckAIWallet); got.Status != launchCheckStatusSkipped {
|
||||
t.Fatalf("non-claw402 wallet check should be skipped, got %+v", got)
|
||||
}
|
||||
if got := findCheck(t, checks, launchCheckAIWalletFunds); got.Status != launchCheckStatusSkipped {
|
||||
t.Fatalf("non-claw402 funds check should be skipped, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIWalletInvalidKey(t *testing.T) {
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString("not-a-key")}
|
||||
checks := checkLaunchAIWallet(model)
|
||||
got := findCheck(t, checks, launchCheckAIWallet)
|
||||
if got.Status != launchCheckStatusFailed || got.Code != "AI_WALLET_INVALID_KEY" {
|
||||
t.Fatalf("invalid key: expected failed/AI_WALLET_INVALID_KEY, got %+v", got)
|
||||
}
|
||||
if funds := findCheck(t, checks, launchCheckAIWalletFunds); funds.Status != launchCheckStatusSkipped {
|
||||
t.Fatalf("funds check should be skipped when the key is invalid, got %+v", funds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIWalletInsufficientFunds(t *testing.T) {
|
||||
withAIWalletBalance(t, 0.25, nil)
|
||||
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
checks := checkLaunchAIWallet(model)
|
||||
|
||||
wallet := findCheck(t, checks, launchCheckAIWallet)
|
||||
if wallet.Status != launchCheckStatusOK || wallet.Address == "" {
|
||||
t.Fatalf("wallet check should pass with derived address, got %+v", wallet)
|
||||
}
|
||||
|
||||
funds := findCheck(t, checks, launchCheckAIWalletFunds)
|
||||
if funds.Status != launchCheckStatusFailed || funds.Code != "AI_WALLET_INSUFFICIENT_FUNDS" {
|
||||
t.Fatalf("expected failed/AI_WALLET_INSUFFICIENT_FUNDS, got %+v", funds)
|
||||
}
|
||||
if funds.Actual == nil || *funds.Actual != 0.25 {
|
||||
t.Fatalf("expected actual balance 0.25, got %+v", funds.Actual)
|
||||
}
|
||||
if funds.Required != MinAIFeeUSDC {
|
||||
t.Fatalf("expected required %v, got %v", MinAIFeeUSDC, funds.Required)
|
||||
}
|
||||
if funds.Address == "" {
|
||||
t.Fatalf("funds check should carry the deposit address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIWalletRPCOutageIsWarningNotFailure(t *testing.T) {
|
||||
withAIWalletBalance(t, 0, errors.New("rpc unreachable"))
|
||||
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
checks := checkLaunchAIWallet(model)
|
||||
|
||||
funds := findCheck(t, checks, launchCheckAIWalletFunds)
|
||||
if funds.Status != launchCheckStatusWarning || funds.Code != "AI_WALLET_BALANCE_UNKNOWN" {
|
||||
t.Fatalf("RPC outage must degrade to warning, got %+v", funds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIWalletFunded(t *testing.T) {
|
||||
withAIWalletBalance(t, 25.5, nil)
|
||||
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
checks := checkLaunchAIWallet(model)
|
||||
|
||||
funds := findCheck(t, checks, launchCheckAIWalletFunds)
|
||||
if funds.Status != launchCheckStatusOK {
|
||||
t.Fatalf("funded wallet should pass, got %+v", funds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeExchangeConfigIssue(t *testing.T) {
|
||||
if _, code := describeExchangeConfigIssue(nil); code != "EXCHANGE_NOT_FOUND" {
|
||||
t.Fatalf("nil exchange: expected EXCHANGE_NOT_FOUND, got %s", code)
|
||||
}
|
||||
|
||||
disabled := &store.Exchange{ID: "ex", ExchangeType: "hyperliquid", Enabled: false}
|
||||
if _, code := describeExchangeConfigIssue(disabled); code != "EXCHANGE_DISABLED" {
|
||||
t.Fatalf("disabled: expected EXCHANGE_DISABLED, got %s", code)
|
||||
}
|
||||
|
||||
missing := &store.Exchange{ID: "ex", ExchangeType: "hyperliquid", Enabled: true}
|
||||
if _, code := describeExchangeConfigIssue(missing); code != "EXCHANGE_MISSING_FIELDS" {
|
||||
t.Fatalf("missing fields: expected EXCHANGE_MISSING_FIELDS, got %s", code)
|
||||
}
|
||||
|
||||
unapproved := &store.Exchange{
|
||||
ID: "ex",
|
||||
ExchangeType: "hyperliquid",
|
||||
Enabled: true,
|
||||
APIKey: crypto.EncryptedString(testClaw402Key),
|
||||
HyperliquidWalletAddr: "0x1111111111111111111111111111111111111111",
|
||||
}
|
||||
if _, code := describeExchangeConfigIssue(unapproved); code != "HYPERLIQUID_BUILDER_NOT_APPROVED" {
|
||||
t.Fatalf("builder unapproved: expected HYPERLIQUID_BUILDER_NOT_APPROVED, got %s", code)
|
||||
}
|
||||
|
||||
unapproved.HyperliquidBuilderApproved = true
|
||||
if _, code := describeExchangeConfigIssue(unapproved); code != "" {
|
||||
t.Fatalf("ready exchange: expected no issue, got %s", code)
|
||||
}
|
||||
}
|
||||
|
||||
func readyHyperliquidExchange() *store.Exchange {
|
||||
return &store.Exchange{
|
||||
ID: "ex-hl",
|
||||
ExchangeType: "hyperliquid",
|
||||
Enabled: true,
|
||||
APIKey: crypto.EncryptedString(testClaw402Key),
|
||||
HyperliquidWalletAddr: "0x1111111111111111111111111111111111111111",
|
||||
HyperliquidBuilderApproved: true,
|
||||
}
|
||||
}
|
||||
|
||||
func preflightTestServer(t *testing.T, userID string, states map[string]ExchangeAccountState) *Server {
|
||||
t.Helper()
|
||||
server := &Server{exchangeAccountStateCache: NewExchangeAccountStateCache()}
|
||||
if states != nil {
|
||||
server.exchangeAccountStateCache.Set(userID, states)
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
func TestCheckLaunchExchangeInsufficientFundsBlocks(t *testing.T) {
|
||||
exchange := readyHyperliquidExchange()
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 5.5, TotalEquity: 5.5},
|
||||
})
|
||||
|
||||
checks := server.checkLaunchExchange("user-1", exchange)
|
||||
funds := findCheck(t, checks, launchCheckExchangeFunds)
|
||||
if funds.Status != launchCheckStatusFailed || funds.Code != "EXCHANGE_INSUFFICIENT_FUNDS" {
|
||||
t.Fatalf("expected failed/EXCHANGE_INSUFFICIENT_FUNDS, got %+v", funds)
|
||||
}
|
||||
if funds.Actual == nil || *funds.Actual != 5.5 {
|
||||
t.Fatalf("expected actual 5.5, got %+v", funds.Actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchExchangeDeployedMarginPasses(t *testing.T) {
|
||||
// A running bot with capital locked in positions: low available balance
|
||||
// but healthy equity. Restart must not be blocked.
|
||||
exchange := readyHyperliquidExchange()
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 3, TotalEquity: 100},
|
||||
})
|
||||
|
||||
checks := server.checkLaunchExchange("user-1", exchange)
|
||||
funds := findCheck(t, checks, launchCheckExchangeFunds)
|
||||
if funds.Status != launchCheckStatusOK {
|
||||
t.Fatalf("deployed margin with healthy equity should pass, got %+v", funds)
|
||||
}
|
||||
if funds.Actual == nil || *funds.Actual != 100 {
|
||||
t.Fatalf("expected actual 100 (equity), got %+v", funds.Actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchExchangeTestnetLowFundsIsWarning(t *testing.T) {
|
||||
exchange := readyHyperliquidExchange()
|
||||
exchange.Testnet = true
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 0},
|
||||
})
|
||||
|
||||
checks := server.checkLaunchExchange("user-1", exchange)
|
||||
funds := findCheck(t, checks, launchCheckExchangeFunds)
|
||||
if funds.Status != launchCheckStatusWarning {
|
||||
t.Fatalf("testnet low funds should warn, not block, got %+v", funds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchExchangeInvalidCredentials(t *testing.T) {
|
||||
exchange := readyHyperliquidExchange()
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {
|
||||
ExchangeID: exchange.ID,
|
||||
Status: exchangeAccountStatusInvalidCredentials,
|
||||
ErrorCode: "INVALID_CREDENTIALS",
|
||||
ErrorMessage: "Exchange credentials are invalid",
|
||||
},
|
||||
})
|
||||
|
||||
checks := server.checkLaunchExchange("user-1", exchange)
|
||||
account := findCheck(t, checks, launchCheckExchangeAccount)
|
||||
if account.Status != launchCheckStatusFailed || account.Code != "INVALID_CREDENTIALS" {
|
||||
t.Fatalf("expected failed/INVALID_CREDENTIALS, got %+v", account)
|
||||
}
|
||||
if funds := findCheck(t, checks, launchCheckExchangeFunds); funds.Status != launchCheckStatusSkipped {
|
||||
t.Fatalf("funds check should be skipped when account probe fails, got %+v", funds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLaunchPreflightAggregatesReadiness(t *testing.T) {
|
||||
withAIWalletBalance(t, 10, nil)
|
||||
|
||||
exchange := readyHyperliquidExchange()
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 100, TotalEquity: 100},
|
||||
})
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
strategy := &store.Strategy{ID: "strat-1", Name: "Autopilot"}
|
||||
|
||||
result := server.runLaunchPreflight("user-1", model, exchange, strategy, true)
|
||||
if !result.Ready {
|
||||
t.Fatalf("expected ready, got %+v", result)
|
||||
}
|
||||
if result.MinAIFeeUSDC != MinAIFeeUSDC || result.MinTradingUSDC != MinTradingUSDC {
|
||||
t.Fatalf("minimums must be exposed in the response, got %+v", result)
|
||||
}
|
||||
|
||||
// Break one prerequisite → not ready, and Summary explains it.
|
||||
withAIWalletBalance(t, 0, nil)
|
||||
result = server.runLaunchPreflight("user-1", model, exchange, strategy, true)
|
||||
if result.Ready {
|
||||
t.Fatalf("expected not ready with empty AI wallet, got %+v", result)
|
||||
}
|
||||
if result.Summary() == "" {
|
||||
t.Fatalf("summary should describe the failing check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLaunchPreflightWarningsDoNotBlock(t *testing.T) {
|
||||
withAIWalletBalance(t, 0, errors.New("rpc down"))
|
||||
|
||||
exchange := readyHyperliquidExchange()
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 100},
|
||||
})
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
|
||||
result := server.runLaunchPreflight("user-1", model, exchange, nil, false)
|
||||
if !result.Ready {
|
||||
t.Fatalf("warnings must not block launch, got %+v", result)
|
||||
}
|
||||
}
|
||||
101
api/ratelimit.go
Normal file
101
api/ratelimit.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ipRateLimiter is a small, dependency-free token-bucket rate limiter keyed by
|
||||
// client IP. It is used to throttle the unauthenticated auth endpoints
|
||||
// (login / register) against online brute-force attacks.
|
||||
//
|
||||
// Design notes:
|
||||
// - Per-IP token bucket with lazy refill (no background goroutine).
|
||||
// - Idle buckets are evicted opportunistically so a flood of distinct source
|
||||
// IPs (e.g. spoofed X-Forwarded-For) cannot grow the map without bound.
|
||||
// - This is a throttle, not an authenticator. Behind a reverse proxy the
|
||||
// effective key is whatever gin's ClientIP() resolves; operators who
|
||||
// terminate TLS at a proxy should configure trusted proxies so ClientIP()
|
||||
// reflects the real peer rather than a spoofable header.
|
||||
type ipRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*rlBucket
|
||||
rate float64 // tokens added per second
|
||||
burst float64 // maximum tokens (and initial fill)
|
||||
lastGC time.Time
|
||||
}
|
||||
|
||||
type rlBucket struct {
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// newIPRateLimiter creates a limiter that allows bursts up to `burst` requests
|
||||
// and then refills at `ratePerSec` tokens/second per client IP.
|
||||
func newIPRateLimiter(ratePerSec, burst float64) *ipRateLimiter {
|
||||
return &ipRateLimiter{
|
||||
buckets: make(map[string]*rlBucket),
|
||||
rate: ratePerSec,
|
||||
burst: burst,
|
||||
}
|
||||
}
|
||||
|
||||
// allow reports whether a request from key is permitted at time now, consuming
|
||||
// one token when it is.
|
||||
func (l *ipRateLimiter) allow(key string, now time.Time) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
// Opportunistic GC: drop buckets idle for >10 minutes. Bounds memory even
|
||||
// under a spoofed-IP flood without needing a background goroutine.
|
||||
if l.lastGC.IsZero() {
|
||||
l.lastGC = now
|
||||
}
|
||||
if now.Sub(l.lastGC) > time.Minute {
|
||||
for k, b := range l.buckets {
|
||||
if now.Sub(b.last) > 10*time.Minute {
|
||||
delete(l.buckets, k)
|
||||
}
|
||||
}
|
||||
l.lastGC = now
|
||||
}
|
||||
|
||||
b, ok := l.buckets[key]
|
||||
if !ok {
|
||||
b = &rlBucket{tokens: l.burst, last: now}
|
||||
l.buckets[key] = b
|
||||
}
|
||||
|
||||
// Refill based on elapsed time, capped at burst.
|
||||
elapsed := now.Sub(b.last).Seconds()
|
||||
if elapsed > 0 {
|
||||
b.tokens = math.Min(l.burst, b.tokens+elapsed*l.rate)
|
||||
b.last = now
|
||||
}
|
||||
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
// rateLimitMiddleware throttles requests per client IP, returning 429 when the
|
||||
// caller exceeds the configured rate.
|
||||
func rateLimitMiddleware(l *ipRateLimiter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !l.allow(c.ClientIP(), time.Now()) {
|
||||
c.Header("Retry-After", "60")
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "Too many requests. Please slow down and try again in a minute.",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
54
api/ratelimit_test.go
Normal file
54
api/ratelimit_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIPRateLimiterBurstThenThrottle verifies that a client gets `burst`
|
||||
// immediate attempts and is then throttled until tokens refill.
|
||||
func TestIPRateLimiterBurstThenThrottle(t *testing.T) {
|
||||
// 1 token/sec, burst of 3.
|
||||
l := newIPRateLimiter(1.0, 3)
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
|
||||
// First 3 requests in the same instant are allowed (the burst).
|
||||
for i := 0; i < 3; i++ {
|
||||
if !l.allow("1.2.3.4", now) {
|
||||
t.Fatalf("request %d in burst should be allowed", i+1)
|
||||
}
|
||||
}
|
||||
// 4th in the same instant is throttled.
|
||||
if l.allow("1.2.3.4", now) {
|
||||
t.Fatalf("request beyond burst should be throttled")
|
||||
}
|
||||
|
||||
// After 1 second, one token refills → exactly one more request allowed.
|
||||
now = now.Add(time.Second)
|
||||
if !l.allow("1.2.3.4", now) {
|
||||
t.Fatalf("one token should have refilled after 1s")
|
||||
}
|
||||
if l.allow("1.2.3.4", now) {
|
||||
t.Fatalf("only one token should refill per second")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIPRateLimiterIsolatesClients verifies one IP exhausting its bucket does
|
||||
// not throttle a different IP.
|
||||
func TestIPRateLimiterIsolatesClients(t *testing.T) {
|
||||
l := newIPRateLimiter(1.0, 2)
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
|
||||
// Exhaust IP A.
|
||||
if !l.allow("10.0.0.1", now) || !l.allow("10.0.0.1", now) {
|
||||
t.Fatalf("IP A burst should be allowed")
|
||||
}
|
||||
if l.allow("10.0.0.1", now) {
|
||||
t.Fatalf("IP A should be throttled after burst")
|
||||
}
|
||||
|
||||
// IP B is unaffected.
|
||||
if !l.allow("10.0.0.2", now) {
|
||||
t.Fatalf("IP B should be allowed regardless of IP A")
|
||||
}
|
||||
}
|
||||
166
api/server.go
166
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())
|
||||
@@ -136,6 +211,11 @@ func (s *Server) setupRoutes() {
|
||||
// Server IP query (requires authentication, for whitelist configuration)
|
||||
s.route(protected, "GET", "/server-ip", "Get server public IP (for exchange whitelist)", s.handleGetServerIP)
|
||||
|
||||
s.route(protected, "GET", "/vergex/signal-ranking", "Vergex signal ranking via claw402 (?marketType=all&limit=30)", s.handleVergexSignalRanking)
|
||||
s.route(protected, "GET", "/vergex/signal-lab", "Vergex signal lab via claw402 (?marketType=hip3_perp&symbol=AAPL)", s.handleVergexSignalLab)
|
||||
s.route(protected, "GET", "/vergex/cost-liquidation-heatmap", "Vergex cost/liquidation heatmap via claw402 (?marketType=hip3_perp&symbol=AAPL)", s.handleVergexCostLiquidationHeatmap)
|
||||
s.route(protected, "GET", "/vergex/flow-markets", "Vergex net-flow market ranking via claw402 (?chain=mainnet&window=1h&limit=25)", s.handleVergexFlowMarkets)
|
||||
|
||||
// AI trader management
|
||||
s.routeWithSchema(protected, "GET", "/my-traders", "List user's traders with status",
|
||||
`Returns: [{"trader_id":"<EXACT id — use this as trader_id in all ?trader_id= queries and POST /traders/:id/start|stop>","trader_name":"<string>","is_running":<bool>}]
|
||||
@@ -145,7 +225,7 @@ NOTE: The id field is "trader_id" (NOT "id"). Always read trader_id from this en
|
||||
`:id = trader_id from GET /api/my-traders`,
|
||||
s.handleGetTraderConfig)
|
||||
s.routeWithSchema(protected, "POST", "/traders", "Create a new AI trader",
|
||||
`Body: {"name":"<string, required>","ai_model_id":"<EXACT id field from GET /api/models — e.g. 'abc123_deepseek', NOT the provider name 'deepseek'>","exchange_id":"<EXACT id field from GET /api/exchanges — e.g. '05785d3b-841e-...', NOT the type name>","strategy_id":"<EXACT id field from GET /api/strategies>","scan_interval_minutes":<int, default 3, minimum 3>}
|
||||
`Body: {"name":"<string, required>","ai_model_id":"<EXACT id field from GET /api/models — e.g. 'abc123_deepseek', NOT the provider name 'deepseek'>","exchange_id":"<EXACT id field from GET /api/exchanges — e.g. '05785d3b-841e-...', NOT the type name>","strategy_id":"<EXACT id field from GET /api/strategies>","scan_interval_minutes":<int, default 15, minimum 3>}
|
||||
IMPORTANT: ai_model_id and exchange_id must be the full "id" value from the Account State, not the provider/type name.`,
|
||||
s.handleCreateTrader)
|
||||
s.routeWithSchema(protected, "PUT", "/traders/:id", "Update trader configuration",
|
||||
@@ -157,8 +237,17 @@ Only include fields you want to change.`,
|
||||
`:id = trader_id from GET /api/my-traders. Stops and permanently removes the trader and all its data.`,
|
||||
s.handleDeleteTrader)
|
||||
s.routeWithSchema(protected, "POST", "/traders/:id/start", "Start trader — begins live trading",
|
||||
`:id = trader_id from GET /api/my-traders. No request body needed. The trader must have a valid exchange and AI model configured.`,
|
||||
`:id = trader_id from GET /api/my-traders. No request body needed. The trader must have a valid exchange and AI model configured.
|
||||
Runs launch preflight first and returns 400 with {"error_key":"trader.start.preflight_failed","preflight":{...}} when checks fail. Append ?force=true to skip balance gates.`,
|
||||
s.handleStartTrader)
|
||||
s.routeWithSchema(protected, "GET", "/traders/:id/preflight", "Run launch readiness checks for a trader",
|
||||
`:id = trader_id from GET /api/my-traders.
|
||||
Returns: {"ready":<bool>,"checks":[{"id":"ai_model|ai_wallet|ai_wallet_funds|strategy|exchange_config|exchange_account|exchange_funds","status":"ok|failed|warning|skipped","code":"<string>","message":"<string>","required":<number>,"actual":<number>,"asset":"<string>","address":"<string>"}],"min_ai_fee_usdc":<number>,"min_trading_usdc":<number>}`,
|
||||
s.handleTraderPreflight)
|
||||
s.routeWithSchema(protected, "POST", "/launch/preflight", "Run launch readiness checks before creating a trader",
|
||||
`Body: {"ai_model_id":"<EXACT id from GET /api/models>","exchange_id":"<EXACT id from GET /api/exchanges>","strategy_id":"<optional, EXACT id from GET /api/strategies>"}
|
||||
Returns the same shape as GET /api/traders/:id/preflight. Use this before POST /api/traders to surface every missing prerequisite at once.`,
|
||||
s.handleLaunchPreflight)
|
||||
s.routeWithSchema(protected, "POST", "/traders/:id/stop", "Stop trader — halts live trading",
|
||||
`:id = trader_id from GET /api/my-traders. No request body needed. Gracefully stops the trading loop.`,
|
||||
s.handleStopTrader)
|
||||
@@ -256,10 +345,10 @@ CRITICAL: Always use the "id" field for strategy_id.`,
|
||||
IMPORTANT: For most use cases just POST {"name":"<name>"} — the backend fills everything in. Only include "config" when the user explicitly requests custom settings (specific coins, custom leverage, custom timeframes).
|
||||
|
||||
StrategyConfig fields:
|
||||
coin_source.source_type: "static"(fixed coin list) | "ai500"(AI top500 ranking) | "oi_top"(OI increasing, suited for long) | "oi_low"(OI decreasing, suited for short) | "mixed"
|
||||
coin_source.static_coins: ["BTCUSDT","ETHUSDT"] — only when source_type="static"
|
||||
coin_source.use_ai500, ai500_limit: number of coins from AI500 pool (default 10)
|
||||
coin_source.use_oi_top/use_oi_low, oi_top_limit/oi_low_limit: OI-based coin selection
|
||||
coin_source.source_type: "vergex_signal" (Claw402/Vergex signal-ranking; default and recommended)
|
||||
coin_source.vergex_limit: number of Claw402 candidates enriched with detail data (default 10, max 10)
|
||||
coin_source.vergex_market_type: "all" for the full Claw402 board; detail calls use each ranking item's market_type
|
||||
coin_source.vergex_chain: "hyperliquid"
|
||||
indicators.klines.primary_timeframe: "1m"|"3m"|"5m"|"15m"|"1h"|"4h" — scalping→"5m", trend/swing→"1h"/"4h"
|
||||
indicators.klines.primary_count: number of candles (20-100)
|
||||
indicators.klines.enable_multi_timeframe: true for trend/swing analysis
|
||||
@@ -356,6 +445,10 @@ Returns the most recent AI decision for each symbol analyzed in the last scan cy
|
||||
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
|
||||
Returns: {"total_trades":<int>,"winning_trades":<int>,"win_rate":<float>,"total_pnl":<float>,"sharpe_ratio":<float>,"max_drawdown":<float>}`,
|
||||
s.handleStatistics)
|
||||
s.routeWithSchema(protected, "GET", "/statistics/full", "Full trade-quality metrics (win rate, profit factor, Sharpe, max drawdown)",
|
||||
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
|
||||
Returns: {"total_trades","win_trades","loss_trades","win_rate","profit_factor","sharpe_ratio","total_pnl","total_fee","avg_win","avg_loss","max_drawdown_pct"}`,
|
||||
s.handleStatisticsFull)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -508,34 +601,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 {
|
||||
// Resolve strictly from the caller's own trader list.
|
||||
userTraders, err := s.store.Trader().List(userID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to load traders for this account: %w", err)
|
||||
}
|
||||
if len(userTraders) == 0 {
|
||||
return nil, "", fmt.Errorf("No available traders")
|
||||
}
|
||||
|
||||
// Get user's trader list, prioritize returning user's own traders
|
||||
userTraders, err := s.store.Trader().List(userID)
|
||||
if err == nil && len(userTraders) > 0 {
|
||||
traderID = userTraders[0].ID
|
||||
} else {
|
||||
traderID = ids[0]
|
||||
}
|
||||
if traderID == "" {
|
||||
// No trader_id specified — default to the caller's first trader.
|
||||
return s.traderManager, userTraders[0].ID, nil
|
||||
}
|
||||
|
||||
// A trader_id was supplied — it must belong to the caller.
|
||||
for _, t := range userTraders {
|
||||
if t.ID == traderID {
|
||||
return s.traderManager, traderID, nil
|
||||
}
|
||||
}
|
||||
return nil, "", fmt.Errorf("trader not found for this account")
|
||||
}
|
||||
|
||||
// authMiddleware JWT authentication middleware
|
||||
|
||||
@@ -2,11 +2,38 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"nofx/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestPublicDecryptRouteNotRegistered is a security regression test: the
|
||||
// unauthenticated POST /api/crypto/decrypt route was a decryption oracle and
|
||||
// must never be re-registered. A built server's router must not route to it.
|
||||
func TestPublicDecryptRouteNotRegistered(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
s := &Server{router: gin.New()}
|
||||
s.setupRoutes()
|
||||
|
||||
for _, r := range s.router.Routes() {
|
||||
if r.Method == http.MethodPost && r.Path == "/api/crypto/decrypt" {
|
||||
t.Fatalf("SECURITY REGRESSION: public decryption oracle POST /api/crypto/decrypt is registered")
|
||||
}
|
||||
}
|
||||
|
||||
// Also assert at the HTTP layer that the route is not handled.
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/crypto/decrypt", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for POST /api/crypto/decrypt, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateTraderRequest_SystemPromptTemplate Test whether SystemPromptTemplate field exists when updating trader
|
||||
func TestUpdateTraderRequest_SystemPromptTemplate(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -20,6 +21,9 @@ import (
|
||||
// validateStrategyConfig validates strategy configuration and returns warnings
|
||||
func validateStrategyConfig(config *store.StrategyConfig) []string {
|
||||
var warnings []string
|
||||
if config.StrategyType == "grid_trading" {
|
||||
return warnings
|
||||
}
|
||||
|
||||
// Validate NofxOS API key if any NofxOS feature is enabled
|
||||
if (config.Indicators.EnableQuantData || config.Indicators.EnableOIRanking ||
|
||||
@@ -31,6 +35,17 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
|
||||
return warnings
|
||||
}
|
||||
|
||||
func attachPublishConfig(config *store.StrategyConfig, strategy *store.Strategy) {
|
||||
if config == nil || strategy == nil {
|
||||
return
|
||||
}
|
||||
config.ClampLimits()
|
||||
config.PublishConfig = &store.PublishStrategyConfig{
|
||||
IsPublic: strategy.IsPublic,
|
||||
ConfigVisible: strategy.ConfigVisible,
|
||||
}
|
||||
}
|
||||
|
||||
// handleEstimateTokens estimates token usage for a strategy config (no auth required, pure computation)
|
||||
func (s *Server) handleEstimateTokens(c *gin.Context) {
|
||||
var req struct {
|
||||
@@ -71,6 +86,7 @@ func (s *Server) handlePublicStrategies(c *gin.Context) {
|
||||
if st.ConfigVisible {
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(st.Config), &config)
|
||||
attachPublishConfig(&config, st)
|
||||
item["config"] = config
|
||||
}
|
||||
|
||||
@@ -90,6 +106,14 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
lang := c.Query("lang")
|
||||
if lang == "" {
|
||||
lang = "zh"
|
||||
}
|
||||
if err := s.createDefaultStrategies(userID, lang); err != nil {
|
||||
logger.Warnf("Failed to sync default strategy presets for user %s: %v", userID, err)
|
||||
}
|
||||
|
||||
strategies, err := s.store.Strategy().List(userID)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to get strategy list", err)
|
||||
@@ -101,6 +125,7 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
|
||||
for _, st := range strategies {
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(st.Config), &config)
|
||||
attachPublishConfig(&config, st)
|
||||
|
||||
result = append(result, gin.H{
|
||||
"id": st.ID,
|
||||
@@ -139,6 +164,7 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
|
||||
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(strategy.Config), &config)
|
||||
attachPublishConfig(&config, strategy)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": strategy.ID,
|
||||
@@ -166,6 +192,8 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
Description string `json:"description"`
|
||||
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
|
||||
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
|
||||
IsPublic bool `json:"is_public"`
|
||||
ConfigVisible bool `json:"config_visible"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -182,6 +210,19 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
defaultCfg := store.GetDefaultStrategyConfig(lang)
|
||||
req.Config = &defaultCfg
|
||||
}
|
||||
beforeClamp := *req.Config
|
||||
req.Config.ClampLimits()
|
||||
hadPublishConfig := req.Config.PublishConfig != nil
|
||||
isPublic := req.IsPublic
|
||||
configVisible := req.ConfigVisible
|
||||
if hadPublishConfig {
|
||||
isPublic = req.Config.PublishConfig.IsPublic
|
||||
configVisible = req.Config.PublishConfig.ConfigVisible
|
||||
}
|
||||
req.Config.PublishConfig = &store.PublishStrategyConfig{
|
||||
IsPublic: isPublic,
|
||||
ConfigVisible: configVisible,
|
||||
}
|
||||
|
||||
// Serialize configuration
|
||||
configJSON, err := json.Marshal(req.Config)
|
||||
@@ -197,6 +238,9 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
Description: req.Description,
|
||||
IsActive: false,
|
||||
IsDefault: false,
|
||||
IsPublic: isPublic,
|
||||
// Existing default is true; keep that behavior when no explicit publish config is sent.
|
||||
ConfigVisible: configVisible || !hadPublishConfig,
|
||||
Config: string(configJSON),
|
||||
}
|
||||
|
||||
@@ -207,6 +251,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
|
||||
// Validate configuration and collect warnings
|
||||
warnings := validateStrategyConfig(req.Config)
|
||||
warnings = append(warnings, store.StrategyClampWarnings(beforeClamp, *req.Config, req.Config.Language)...)
|
||||
|
||||
response := gin.H{
|
||||
"id": strategy.ID,
|
||||
@@ -263,14 +308,21 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
mergedConfig = store.StrategyConfig{}
|
||||
}
|
||||
|
||||
// Apply incoming config on top: top-level sections present in the request overwrite
|
||||
// their corresponding existing section; absent sections remain unchanged.
|
||||
// Apply incoming config on top while preserving nested fields that were not sent.
|
||||
if len(req.Config) > 0 && string(req.Config) != "null" {
|
||||
if err := json.Unmarshal(req.Config, &mergedConfig); err != nil {
|
||||
var patch map[string]any
|
||||
if err := json.Unmarshal(req.Config, &patch); err != nil {
|
||||
SafeBadRequest(c, "Invalid config JSON")
|
||||
return
|
||||
}
|
||||
mergedConfig, err = store.MergeStrategyConfig(mergedConfig, patch)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid config JSON")
|
||||
return
|
||||
}
|
||||
}
|
||||
beforeClamp := mergedConfig
|
||||
mergedConfig.ClampLimits()
|
||||
|
||||
// Preserve existing name/description when not supplied
|
||||
name := req.Name
|
||||
@@ -324,6 +376,7 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
|
||||
// Validate merged configuration and collect warnings
|
||||
warnings := validateStrategyConfig(&mergedConfig)
|
||||
warnings = append(warnings, store.StrategyClampWarnings(beforeClamp, mergedConfig, mergedConfig.Language)...)
|
||||
|
||||
response := gin.H{"message": "Strategy updated successfully"}
|
||||
if len(warnings) > 0 {
|
||||
@@ -417,6 +470,7 @@ func (s *Server) handleGetActiveStrategy(c *gin.Context) {
|
||||
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(strategy.Config), &config)
|
||||
attachPublishConfig(&config, strategy)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": strategy.ID,
|
||||
@@ -516,8 +570,17 @@ func (s *Server) handleStrategyTestRun(c *gin.Context) {
|
||||
req.PromptVariant = "balanced"
|
||||
}
|
||||
|
||||
claw402WalletKey, err := s.resolveStrategyDataWalletKey(userID, req.AIModelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": err.Error(),
|
||||
"ai_response": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create strategy engine to build prompt
|
||||
engine := kernel.NewStrategyEngine(&req.Config)
|
||||
engine := kernel.NewStrategyEngine(&req.Config, claw402WalletKey)
|
||||
|
||||
// Get candidate coins
|
||||
candidates, err := engine.GetCandidateCoins()
|
||||
@@ -574,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()
|
||||
@@ -604,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,
|
||||
@@ -697,3 +762,7 @@ func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *Server) resolveStrategyDataWalletKey(userID, selectedModelID string) (string, error) {
|
||||
return s.store.AIModel().ResolveClaw402WalletKey(userID, selectedModelID)
|
||||
}
|
||||
|
||||
36
api/utils.go
36
api/utils.go
@@ -15,13 +15,10 @@ func MaskSensitiveString(s string) string {
|
||||
return s[:4] + "****" + s[length-4:]
|
||||
}
|
||||
|
||||
// SanitizeModelConfigForLog Sanitize model configuration for log output
|
||||
func SanitizeModelConfigForLog(models map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
CustomAPIURL string `json:"custom_api_url"`
|
||||
CustomModelName string `json:"custom_model_name"`
|
||||
}) map[string]interface{} {
|
||||
// SanitizeModelConfigForLog Sanitize model configuration for log output.
|
||||
// Takes the same ModelConfigUpdate type used by the request handler so the two
|
||||
// can never drift out of sync.
|
||||
func SanitizeModelConfigForLog(models map[string]ModelConfigUpdate) map[string]interface{} {
|
||||
safe := make(map[string]interface{})
|
||||
for modelID, cfg := range models {
|
||||
safe[modelID] = map[string]interface{}{
|
||||
@@ -34,19 +31,12 @@ func SanitizeModelConfigForLog(models map[string]struct {
|
||||
return safe
|
||||
}
|
||||
|
||||
// SanitizeExchangeConfigForLog Sanitize exchange configuration for log output
|
||||
func SanitizeExchangeConfigForLog(exchanges map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
}) map[string]interface{} {
|
||||
// SanitizeExchangeConfigForLog Sanitize exchange configuration for log output.
|
||||
// Takes the same ExchangeConfigUpdate type used by the request handler so every
|
||||
// sensitive field is guaranteed to be masked — adding a field to the request
|
||||
// type without masking it here would not compile around this helper, but more
|
||||
// importantly keeps the masking exhaustive.
|
||||
func SanitizeExchangeConfigForLog(exchanges map[string]ExchangeConfigUpdate) map[string]interface{} {
|
||||
safe := make(map[string]interface{})
|
||||
for exchangeID, cfg := range exchanges {
|
||||
safeExchange := map[string]interface{}{
|
||||
@@ -61,12 +51,18 @@ func SanitizeExchangeConfigForLog(exchanges map[string]struct {
|
||||
if cfg.SecretKey != "" {
|
||||
safeExchange["secret_key"] = MaskSensitiveString(cfg.SecretKey)
|
||||
}
|
||||
if cfg.Passphrase != "" {
|
||||
safeExchange["passphrase"] = MaskSensitiveString(cfg.Passphrase)
|
||||
}
|
||||
if cfg.AsterPrivateKey != "" {
|
||||
safeExchange["aster_private_key"] = MaskSensitiveString(cfg.AsterPrivateKey)
|
||||
}
|
||||
if cfg.LighterPrivateKey != "" {
|
||||
safeExchange["lighter_private_key"] = MaskSensitiveString(cfg.LighterPrivateKey)
|
||||
}
|
||||
if cfg.LighterAPIKeyPrivateKey != "" {
|
||||
safeExchange["lighter_api_key_private_key"] = MaskSensitiveString(cfg.LighterAPIKeyPrivateKey)
|
||||
}
|
||||
|
||||
// Add non-sensitive fields directly
|
||||
if cfg.HyperliquidWalletAddr != "" {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -48,12 +50,7 @@ func TestMaskSensitiveString(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSanitizeModelConfigForLog(t *testing.T) {
|
||||
models := map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
CustomAPIURL string `json:"custom_api_url"`
|
||||
CustomModelName string `json:"custom_model_name"`
|
||||
}{
|
||||
models := map[string]ModelConfigUpdate{
|
||||
"deepseek": {
|
||||
Enabled: true,
|
||||
APIKey: "sk-1234567890abcdefghijklmnopqrstuvwxyz",
|
||||
@@ -88,32 +85,29 @@ func TestSanitizeModelConfigForLog(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSanitizeExchangeConfigForLog(t *testing.T) {
|
||||
exchanges := map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
}{
|
||||
exchanges := map[string]ExchangeConfigUpdate{
|
||||
"binance": {
|
||||
Enabled: true,
|
||||
APIKey: "binance_api_key_1234567890abcdef",
|
||||
SecretKey: "binance_secret_key_1234567890abcdef",
|
||||
Testnet: false,
|
||||
LighterWalletAddr: "",
|
||||
LighterPrivateKey: "",
|
||||
},
|
||||
"okx": {
|
||||
Enabled: true,
|
||||
APIKey: "okx_api_key_1234567890abcdef",
|
||||
SecretKey: "okx_secret_key_1234567890abcdef",
|
||||
Passphrase: "okx_passphrase_supersecret_value",
|
||||
},
|
||||
"lighter": {
|
||||
Enabled: true,
|
||||
LighterWalletAddr: "0xabcdef0000000000000000000000000000000000",
|
||||
LighterPrivateKey: "lighter_private_key_1234567890abcdef",
|
||||
LighterAPIKeyPrivateKey: "lighter_api_key_private_key_1234567890abcdef",
|
||||
},
|
||||
"hyperliquid": {
|
||||
Enabled: true,
|
||||
HyperliquidWalletAddr: "0x1234567890abcdef1234567890abcdef12345678",
|
||||
Testnet: false,
|
||||
LighterWalletAddr: "",
|
||||
LighterPrivateKey: "",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -143,6 +137,32 @@ func TestSanitizeExchangeConfigForLog(t *testing.T) {
|
||||
t.Errorf("expected masked secret_key='bina****cdef', got %q", maskedSecretKey)
|
||||
}
|
||||
|
||||
// Check OKX passphrase is masked (regression: previously not covered)
|
||||
okxConfig, ok := result["okx"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("okx config not found or wrong type")
|
||||
}
|
||||
maskedPassphrase, ok := okxConfig["passphrase"].(string)
|
||||
if !ok {
|
||||
t.Fatal("okx passphrase not found or wrong type")
|
||||
}
|
||||
if maskedPassphrase != "okx_****alue" {
|
||||
t.Errorf("expected masked passphrase='okx_****alue', got %q", maskedPassphrase)
|
||||
}
|
||||
|
||||
// Check Lighter API key private key is masked (regression: previously not covered)
|
||||
lighterConfig, ok := result["lighter"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("lighter config not found or wrong type")
|
||||
}
|
||||
maskedLighterAPIKey, ok := lighterConfig["lighter_api_key_private_key"].(string)
|
||||
if !ok {
|
||||
t.Fatal("lighter_api_key_private_key not found or wrong type")
|
||||
}
|
||||
if maskedLighterAPIKey != "ligh****cdef" {
|
||||
t.Errorf("expected masked lighter_api_key_private_key='ligh****cdef', got %q", maskedLighterAPIKey)
|
||||
}
|
||||
|
||||
// Check Hyperliquid configuration
|
||||
hlConfig, ok := result["hyperliquid"].(map[string]interface{})
|
||||
if !ok {
|
||||
@@ -160,6 +180,41 @@ func TestSanitizeExchangeConfigForLog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeExchangeConfigForLog_NoPlaintextSecrets renders the sanitized log
|
||||
// output exactly as the handler does (`%+v`) and asserts that no plaintext
|
||||
// secret — including the passphrase and lighter API key private key that were
|
||||
// historically not redacted — survives into the log line.
|
||||
func TestSanitizeExchangeConfigForLog_NoPlaintextSecrets(t *testing.T) {
|
||||
secrets := map[string]string{
|
||||
"api_key": "binance_api_key_1234567890abcdef",
|
||||
"secret_key": "binance_secret_key_1234567890abcdef",
|
||||
"passphrase": "okx_passphrase_supersecret_value",
|
||||
"aster_private_key": "aster_private_key_1234567890abcdef",
|
||||
"lighter_private_key": "lighter_private_key_1234567890abcdef",
|
||||
"lighter_api_key_private_key": "lighter_api_key_private_key_1234567890abcdef",
|
||||
}
|
||||
|
||||
exchanges := map[string]ExchangeConfigUpdate{
|
||||
"okx": {
|
||||
Enabled: true,
|
||||
APIKey: secrets["api_key"],
|
||||
SecretKey: secrets["secret_key"],
|
||||
Passphrase: secrets["passphrase"],
|
||||
AsterPrivateKey: secrets["aster_private_key"],
|
||||
LighterPrivateKey: secrets["lighter_private_key"],
|
||||
LighterAPIKeyPrivateKey: secrets["lighter_api_key_private_key"],
|
||||
},
|
||||
}
|
||||
|
||||
rendered := fmt.Sprintf("%+v", SanitizeExchangeConfigForLog(exchanges))
|
||||
|
||||
for field, secret := range secrets {
|
||||
if strings.Contains(rendered, secret) {
|
||||
t.Errorf("sanitized log leaked plaintext %s: %q present in %q", field, secret, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
228
cli.go
Normal file
228
cli.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"nofx/auth"
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"golang.org/x/term"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// minResetPasswordLen mirrors the minimum enforced on the authenticated
|
||||
// password-change path (PUT /api/user/password).
|
||||
const minResetPasswordLen = 8
|
||||
|
||||
// runCLISubcommand dispatches local admin subcommands.
|
||||
//
|
||||
// SECURITY: account recovery (reset-password, reset-account) is intentionally
|
||||
// NOT exposed over HTTP. Performing it requires running this binary on the host,
|
||||
// which in turn requires shell/file access to the server. A remote attacker on a
|
||||
// public-facing deployment has only the network — they can reach the API but not
|
||||
// a local process — so recovery cannot be triggered remotely. This is what makes
|
||||
// the recovery path safe even when NOFX is deployed on the public internet.
|
||||
//
|
||||
// Returns true if a subcommand was recognized and handled (caller should exit).
|
||||
// Unknown first args fall through to false to preserve the historical behavior
|
||||
// where `nofx <dbpath>` overrides the SQLite path.
|
||||
func runCLISubcommand(args []string) bool {
|
||||
if len(args) == 0 {
|
||||
return false
|
||||
}
|
||||
switch args[0] {
|
||||
case "reset-password":
|
||||
runResetPassword(args[1:])
|
||||
return true
|
||||
case "reset-account":
|
||||
runResetAccount(args[1:])
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// openStoreForCLI loads config + encryption and opens the same database the
|
||||
// server uses, so subcommands operate on the live data.
|
||||
func openStoreForCLI(dbPathOverride string) (*store.Store, error) {
|
||||
_ = godotenv.Load()
|
||||
logger.Init(nil)
|
||||
config.MustInit()
|
||||
cfg := config.Get()
|
||||
if strings.TrimSpace(dbPathOverride) != "" {
|
||||
cfg.DBPath = dbPathOverride
|
||||
}
|
||||
|
||||
cryptoService, err := crypto.NewCryptoService()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("initialize encryption service: %w", err)
|
||||
}
|
||||
crypto.SetGlobalCryptoService(cryptoService)
|
||||
|
||||
if cfg.DBType == "sqlite" {
|
||||
if dir := filepath.Dir(cfg.DBPath); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create data directory: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dbType := store.DBTypeSQLite
|
||||
if cfg.DBType == "postgres" {
|
||||
dbType = store.DBTypePostgres
|
||||
}
|
||||
return store.NewWithConfig(store.DBConfig{
|
||||
Type: dbType,
|
||||
Path: cfg.DBPath,
|
||||
Host: cfg.DBHost,
|
||||
Port: cfg.DBPort,
|
||||
User: cfg.DBUser,
|
||||
Password: cfg.DBPassword,
|
||||
DBName: cfg.DBName,
|
||||
SSLMode: cfg.DBSSLMode,
|
||||
})
|
||||
}
|
||||
|
||||
// runResetPassword resets the password for a single account from the command
|
||||
// line. Usage: `nofx reset-password --email you@example.com`.
|
||||
func runResetPassword(args []string) {
|
||||
fs := flag.NewFlagSet("reset-password", flag.ExitOnError)
|
||||
email := fs.String("email", "", "email of the account to reset (required)")
|
||||
password := fs.String("password", "", "new password (min 8 chars); omit to enter it interactively")
|
||||
dbPath := fs.String("db", "", "override SQLite DB path (defaults to config / DB_PATH)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
if strings.TrimSpace(*email) == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: --email is required")
|
||||
fmt.Fprintln(os.Stderr, "usage: nofx reset-password --email you@example.com")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
st, err := openStoreForCLI(*dbPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
user, err := st.User().GetByEmail(strings.TrimSpace(*email))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: no account found for %q\n", strings.TrimSpace(*email))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
newPassword, err := resolveNewPassword(*password)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to hash password: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := st.User().UpdatePassword(user.ID, hash); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to update password: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Password reset for %s. Log in with the new password.\n", user.Email)
|
||||
}
|
||||
|
||||
// runResetAccount wipes the database back to an uninitialized state. This is the
|
||||
// destructive "forgot everything" recovery, moved off the public API.
|
||||
func runResetAccount(args []string) {
|
||||
fs := flag.NewFlagSet("reset-account", flag.ExitOnError)
|
||||
dbPath := fs.String("db", "", "override SQLite DB path (defaults to config / DB_PATH)")
|
||||
yes := fs.Bool("yes", false, "skip the interactive confirmation prompt")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
if !*yes {
|
||||
fmt.Print("This permanently deletes ALL users, traders, strategies, AI models and\n" +
|
||||
"exchanges — including wallet keys and exchange credentials.\n" +
|
||||
"Type 'wipe' to confirm: ")
|
||||
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if strings.TrimSpace(line) != "wipe" {
|
||||
fmt.Fprintln(os.Stderr, "aborted")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
st, err := openStoreForCLI(*dbPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
err = st.Transaction(func(tx *gorm.DB) error {
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{})
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{})
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.AIModel{})
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Exchange{})
|
||||
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete users: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: failed to reset account: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("✓ System wiped. Register a fresh account and re-import everything.")
|
||||
}
|
||||
|
||||
// resolveNewPassword returns the new password from the --password flag, or
|
||||
// prompts for it (hidden) on a TTY, or reads a single line from piped stdin.
|
||||
func resolveNewPassword(flagValue string) (string, error) {
|
||||
if flagValue != "" {
|
||||
if len(flagValue) < minResetPasswordLen {
|
||||
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
|
||||
}
|
||||
return flagValue, nil
|
||||
}
|
||||
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
fmt.Printf("New password (min %d chars): ", minResetPasswordLen)
|
||||
first, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
fmt.Print("Confirm new password: ")
|
||||
second, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read password: %w", err)
|
||||
}
|
||||
if string(first) != string(second) {
|
||||
return "", errors.New("passwords do not match")
|
||||
}
|
||||
if len(first) < minResetPasswordLen {
|
||||
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
|
||||
}
|
||||
return string(first), nil
|
||||
}
|
||||
|
||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
password := strings.TrimRight(line, "\r\n")
|
||||
if password == "" {
|
||||
return "", fmt.Errorf("no password provided on stdin: %w", err)
|
||||
}
|
||||
if len(password) < minResetPasswordLen {
|
||||
return "", fmt.Errorf("password must be at least %d characters", minResetPasswordLen)
|
||||
}
|
||||
return password, nil
|
||||
}
|
||||
120
cmd/e2e_builder_fee/main.go
Normal file
120
cmd/e2e_builder_fee/main.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"nofx/config"
|
||||
nofxcrypto "nofx/crypto"
|
||||
"nofx/store"
|
||||
hltrader "nofx/trader/hyperliquid"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type clearinghouseState struct {
|
||||
CrossMarginSummary struct {
|
||||
AccountValue string `json:"accountValue"`
|
||||
} `json:"crossMarginSummary"`
|
||||
Withdrawable string `json:"withdrawable"`
|
||||
AssetPositions []struct {
|
||||
Position struct {
|
||||
Coin string `json:"coin"`
|
||||
Szi string `json:"szi"`
|
||||
EntryPx string `json:"entryPx"`
|
||||
PositionValue string `json:"positionValue"`
|
||||
} `json:"position"`
|
||||
} `json:"assetPositions"`
|
||||
}
|
||||
|
||||
func fetchState(wallet string) (*clearinghouseState, error) {
|
||||
body := strings.NewReader(fmt.Sprintf(`{"type":"clearinghouseState","user":%q}`, wallet))
|
||||
resp, err := http.Post("https://api.hyperliquid.xyz/info", "application/json", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var state clearinghouseState
|
||||
if err := json.NewDecoder(resp.Body).Decode(&state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func positionSize(state *clearinghouseState, coin string) float64 {
|
||||
for _, ap := range state.AssetPositions {
|
||||
if strings.EqualFold(ap.Position.Coin, coin) {
|
||||
v, _ := strconv.ParseFloat(ap.Position.Szi, 64)
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
config.Init()
|
||||
cryptoService, err := nofxcrypto.NewCryptoService()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
nofxcrypto.SetGlobalCryptoService(cryptoService)
|
||||
cfg := config.Get()
|
||||
st, err := store.NewWithConfig(store.DBConfig{Type: store.DBTypeSQLite, Path: cfg.DBPath})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
var ex store.Exchange
|
||||
if err := st.GormDB().Where("exchange_type = ? AND enabled = ? AND hyperliquid_wallet_addr <> ''", "hyperliquid", true).First(&ex).Error; err != nil {
|
||||
panic(fmt.Errorf("no enabled Hyperliquid exchange with wallet/private key found: %w", err))
|
||||
}
|
||||
if strings.TrimSpace(string(ex.APIKey)) == "" {
|
||||
panic("Hyperliquid exchange has empty decrypted agent private key")
|
||||
}
|
||||
|
||||
fmt.Printf("E2E exchange=%s account=%s wallet=%s testnet=%v builderApprovedFlag=%v\n", ex.ID, ex.AccountName, ex.HyperliquidWalletAddr, ex.Testnet, ex.HyperliquidBuilderApproved)
|
||||
before, err := fetchState(ex.HyperliquidWalletAddr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("BEFORE accountValue=%s withdrawable=%s HOOD_szi=%.6f\n", before.CrossMarginSummary.AccountValue, before.Withdrawable, positionSize(before, "xyz:HOOD"))
|
||||
|
||||
tr, err := hltrader.NewHyperliquidTrader(string(ex.APIKey), ex.HyperliquidWalletAddr, false, ex.HyperliquidUnifiedAcct)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
const symbol = "HOOD-USDC"
|
||||
const qty = 0.15
|
||||
fmt.Printf("OPEN_LONG symbol=%s qty=%.3f builderRequired=true\n", symbol, qty)
|
||||
if _, err := tr.OpenLong(symbol, qty, 1); err != nil {
|
||||
panic(fmt.Errorf("open long failed: %w", err))
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
mid, _ := fetchState(ex.HyperliquidWalletAddr)
|
||||
pos := positionSize(mid, "xyz:HOOD")
|
||||
fmt.Printf("AFTER_OPEN HOOD_szi=%.6f\n", pos)
|
||||
closeQty := qty
|
||||
if pos > 0 && pos < closeQty {
|
||||
closeQty = pos
|
||||
}
|
||||
if closeQty > 0 {
|
||||
fmt.Printf("CLOSE_LONG symbol=%s qty=%.6f builderRequired=true\n", symbol, closeQty)
|
||||
if _, err := tr.CloseLong(symbol, closeQty); err != nil {
|
||||
panic(fmt.Errorf("close long failed; manual intervention may be needed for %s size %.6f: %w", symbol, closeQty, err))
|
||||
}
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
after, err := fetchState(ex.HyperliquidWalletAddr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("AFTER_CLOSE accountValue=%s withdrawable=%s HOOD_szi=%.6f\n", after.CrossMarginSummary.AccountValue, after.Withdrawable, positionSize(after, "xyz:HOOD"))
|
||||
fmt.Fprintln(os.Stdout, "E2E_BUILDER_FEE_REAL_XYZ_STOCK_TRADE_DONE")
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"nofx/telemetry"
|
||||
"fmt"
|
||||
"nofx/mcp"
|
||||
"nofx/telemetry"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// insecureDefaultJWTSecret is the historical fallback value. Refusing to boot when
|
||||
// JWT_SECRET matches it (or is missing) prevents the server from silently signing
|
||||
// tokens with a well-known secret.
|
||||
const insecureDefaultJWTSecret = "default-jwt-secret-change-in-production"
|
||||
|
||||
// minJWTSecretLength is the minimum byte length we accept for HS256 signing keys.
|
||||
// HS256 keys shorter than 32 bytes are brute-forceable.
|
||||
const minJWTSecretLength = 32
|
||||
|
||||
// Global configuration instance
|
||||
var global *Config
|
||||
|
||||
@@ -45,8 +55,24 @@ type Config struct {
|
||||
|
||||
}
|
||||
|
||||
// Init initializes global configuration (from .env)
|
||||
// MustInit initializes global configuration or panics. Use from main() so the
|
||||
// process refuses to start under an insecure config (e.g. default JWT secret).
|
||||
func MustInit() {
|
||||
if err := initConfig(); err != nil {
|
||||
panic(fmt.Sprintf("config: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes global configuration (from .env). Prefer MustInit from main.
|
||||
func Init() {
|
||||
if err := initConfig(); err != nil {
|
||||
// Preserve historical fail-soft behavior for non-main callers (tests, tools);
|
||||
// the process can still observe the error via Get() returning nil.
|
||||
fmt.Fprintf(os.Stderr, "config init failed: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func initConfig() error {
|
||||
cfg := &Config{
|
||||
APIServerPort: 8080,
|
||||
ExperienceImprovement: true, // Default: enabled to help improve the product
|
||||
@@ -65,7 +91,13 @@ func Init() {
|
||||
cfg.JWTSecret = strings.TrimSpace(v)
|
||||
}
|
||||
if cfg.JWTSecret == "" {
|
||||
cfg.JWTSecret = "default-jwt-secret-change-in-production"
|
||||
return fmt.Errorf("JWT_SECRET is required (set a random %d+ byte value in .env)", minJWTSecretLength)
|
||||
}
|
||||
if cfg.JWTSecret == insecureDefaultJWTSecret {
|
||||
return fmt.Errorf("JWT_SECRET matches the insecure default; generate a fresh random value (e.g. `openssl rand -base64 48`)")
|
||||
}
|
||||
if len(cfg.JWTSecret) < minJWTSecretLength {
|
||||
return fmt.Errorf("JWT_SECRET must be at least %d bytes (got %d); generate via `openssl rand -base64 48`", minJWTSecretLength, len(cfg.JWTSecret))
|
||||
}
|
||||
|
||||
if v := os.Getenv("API_SERVER_PORT"); v != "" {
|
||||
@@ -134,6 +166,7 @@ func Init() {
|
||||
OutputTokens: usage.CompletionTokens,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the global configuration
|
||||
|
||||
@@ -282,13 +282,17 @@ func isEncryptedStorageValue(value string) bool {
|
||||
}
|
||||
|
||||
func (cs *CryptoService) DecryptPayload(payload *EncryptedPayload) ([]byte, error) {
|
||||
// 1. Validate timestamp (prevent replay attacks)
|
||||
if payload.TS != 0 {
|
||||
// 1. Validate timestamp (prevent replay attacks).
|
||||
// The timestamp is mandatory: a missing/zero ts previously skipped this check
|
||||
// entirely, which let a captured ciphertext be replayed indefinitely. The
|
||||
// client (web/src/lib/crypto.ts) always stamps ts, so requiring it is safe.
|
||||
if payload.TS == 0 {
|
||||
return nil, errors.New("missing timestamp")
|
||||
}
|
||||
elapsed := time.Since(time.Unix(payload.TS, 0))
|
||||
if elapsed > 5*time.Minute || elapsed < -1*time.Minute {
|
||||
return nil, errors.New("timestamp invalid or expired")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Decode base64url
|
||||
wrappedKey, err := base64.RawURLEncoding.DecodeString(payload.WrappedKey)
|
||||
@@ -455,8 +459,11 @@ func (es EncryptedString) Value() (driver.Value, error) {
|
||||
if globalCryptoService != nil {
|
||||
encrypted, err := globalCryptoService.EncryptForStorage(string(es))
|
||||
if err != nil {
|
||||
// If encryption fails, return the original value
|
||||
return string(es), nil
|
||||
// Fail closed: never silently persist a plaintext secret when
|
||||
// encryption was expected to happen. Returning the error aborts the
|
||||
// write so a misconfigured/broken crypto service cannot leak
|
||||
// credentials into the database in cleartext.
|
||||
return nil, fmt.Errorf("failed to encrypt sensitive field for storage: %w", err)
|
||||
}
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
@@ -9,8 +9,14 @@ services:
|
||||
stop_grace_period: 30s # Allow the app 30 seconds for graceful shutdown
|
||||
ports:
|
||||
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
||||
- "6060:6060" # pprof profiling
|
||||
# pprof profiling is bound to host loopback only; uncomment for local debug.
|
||||
# - "127.0.0.1:6060:6060"
|
||||
volumes:
|
||||
# NOTE: .env is bind-mounted so the beginner-onboarding flow
|
||||
# (persistBeginnerWalletEnv) can write CLAW402_WALLET_* back to the host
|
||||
# file. Without this mount the wallet is regenerated on every container
|
||||
# restart. For threat models where the .env file should not be reachable
|
||||
# via container RCE, deploy via env vars only and remove this mount.
|
||||
- ./.env:/app/.env
|
||||
- ./data:/app/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
|
||||
203
docs/agent-skills/diagnostic-skills.zh-CN.md
Normal file
203
docs/agent-skills/diagnostic-skills.zh-CN.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# NOFXi 诊断与配置 Skills(第一批)
|
||||
|
||||
这份文档用于沉淀交易智能助手的第一批高频诊断与配置 skill。
|
||||
|
||||
目标不是让模型“更会想”,而是让它面对常见问题时,优先走稳定、可复用的排查路径。
|
||||
|
||||
## 设计原则
|
||||
|
||||
- 优先按 skill 回答,不要对高频问题重复自由规划
|
||||
- 先归类问题,再给出原因、检查项和修复建议
|
||||
- 能通过工具验证当前状态时,先查再下结论
|
||||
- 敏感信息只指导填写,不完整回显
|
||||
- 对结论不确定时,要明确标注为“更可能”或“优先怀疑”
|
||||
|
||||
## skill_model_api_setup
|
||||
|
||||
### 适用场景
|
||||
|
||||
- 用户问某个大模型的 API key 去哪里申请
|
||||
- 用户问 base URL 怎么填
|
||||
- 用户问 model name 怎么填
|
||||
- 用户问 OpenAI / Claude / Gemini / DeepSeek / Qwen / Kimi / Grok / MiniMax 怎么接入
|
||||
|
||||
### 处理策略
|
||||
|
||||
1. 先确认用户要配置哪个 provider
|
||||
2. 告诉用户需要准备的最少字段:
|
||||
- provider
|
||||
- API key
|
||||
- custom_api_url
|
||||
- custom_model_name
|
||||
3. 如果系统已有默认地址和默认模型名,优先给推荐值
|
||||
4. 回答按步骤组织,不要泛泛解释概念
|
||||
|
||||
### 已知实现事实
|
||||
|
||||
- 系统内置 provider 默认运行配置,见 `agent.resolveModelRuntimeConfig(...)`
|
||||
- 常见 provider 已有默认 URL 和默认 model name
|
||||
|
||||
## skill_model_config_diagnosis
|
||||
|
||||
### 适用场景
|
||||
|
||||
- 模型保存成功但 agent 仍然不可用
|
||||
- 提示 AI unavailable
|
||||
- 提示模型没启用
|
||||
- 提示 custom_api_url 不合法
|
||||
- 配置后 trader 不生效
|
||||
|
||||
### 优先排查
|
||||
|
||||
1. 是否存在已启用模型
|
||||
2. API key 是否为空
|
||||
3. custom_api_url 是否为合法 HTTPS 地址
|
||||
4. custom_model_name 是否为空或不匹配
|
||||
5. 当前 trader 是否绑定了这个模型
|
||||
6. 更新模型后是否已触发 trader reload
|
||||
|
||||
### 已知实现事实
|
||||
|
||||
- 非 HTTPS 的 `custom_api_url` 会被后端拒绝,见 `api/handler_ai_model.go`
|
||||
- 已启用模型如果缺少 API Key 或 URL,会导致 agent 无法就绪,见 `agent.ensureAIClientForStoreUser(...)`
|
||||
- 更新模型配置后,系统会尝试移除并重载相关 trader,使新配置立即生效
|
||||
|
||||
### 输出格式
|
||||
|
||||
- 现象
|
||||
- 更可能原因
|
||||
- 先检查什么
|
||||
- 下一步怎么修复
|
||||
|
||||
## skill_exchange_api_setup
|
||||
|
||||
### 适用场景
|
||||
|
||||
- 用户要新建交易所 API
|
||||
- 用户不知道交易所需要哪些权限
|
||||
- 用户问 API key / secret / passphrase 分别填什么
|
||||
|
||||
### 通用处理策略
|
||||
|
||||
1. 先确认交易所类型
|
||||
2. 告知必须权限与禁止权限
|
||||
3. 告知是否需要额外字段
|
||||
4. 强调 IP 白名单与权限配置
|
||||
5. 引导用户回到系统内完成绑定
|
||||
|
||||
### 特殊规则
|
||||
|
||||
- OKX 除 API Key 和 Secret 外,还需要 passphrase
|
||||
- Bybit 永续/合约交易需要合约权限
|
||||
- 不建议开启提现权限
|
||||
|
||||
### 参考文档
|
||||
|
||||
- `docs/getting-started/okx-api.md`
|
||||
- `docs/getting-started/bybit-api.md`
|
||||
|
||||
## skill_exchange_api_diagnosis
|
||||
|
||||
### 适用场景
|
||||
|
||||
- `invalid signature`
|
||||
- `timestamp` 错误
|
||||
- `IP not allowed`
|
||||
- `permission denied`
|
||||
- 交易所连接不上
|
||||
|
||||
### 优先排查
|
||||
|
||||
1. 系统时间是否同步
|
||||
2. API Key / Secret 是否正确
|
||||
3. 是否遗漏额外字段,如 OKX passphrase
|
||||
4. IP 白名单是否包含当前服务器
|
||||
5. 是否启用了交易或合约权限
|
||||
6. 密钥是否过期或已重建
|
||||
|
||||
### 已知实现事实
|
||||
|
||||
- 时间不同步是 `invalid signature` / `timestamp` 的高频根因,见 `docs/guides/TROUBLESHOOTING.zh-CN.md`
|
||||
- OKX 的 passphrase 缺失会导致签名相关问题,见 `docs/getting-started/okx-api.md`
|
||||
|
||||
### 输出格式
|
||||
|
||||
- 报错现象
|
||||
- 最常见根因
|
||||
- 优先检查顺序
|
||||
- 修复步骤
|
||||
|
||||
## skill_trader_start_diagnosis
|
||||
|
||||
### 适用场景
|
||||
|
||||
- trader 启动不了
|
||||
- trader 启动了但没开始交易
|
||||
- 页面显示已启动但一直没有动作
|
||||
- 用户怀疑 strategy / model / exchange 绑定有问题
|
||||
|
||||
### 优先排查
|
||||
|
||||
1. 是否有已启用的模型配置
|
||||
2. 是否有已启用的交易所配置
|
||||
3. trader 是否绑定了 exchange_id / strategy_id / ai_model_id
|
||||
4. 交易所余额和权限是否满足下单条件
|
||||
5. AI 最近的决策到底是 wait、hold 还是下单失败
|
||||
|
||||
### 回答原则
|
||||
|
||||
- 要区分“没启动”“启动了但 AI 选择不交易”“尝试下单但失败”这三类
|
||||
- 不要把“没开仓”直接等同于“系统故障”
|
||||
|
||||
## skill_order_execution_diagnosis
|
||||
|
||||
### 适用场景
|
||||
|
||||
- 下单失败
|
||||
- 只开空不开户 / 只开单边
|
||||
- 杠杆报错
|
||||
- position side mismatch
|
||||
|
||||
### 优先排查
|
||||
|
||||
1. 账户模式是否匹配,例如 Binance 是否为 Hedge Mode
|
||||
2. 是否为子账户杠杆限制
|
||||
3. 合约权限是否开启
|
||||
4. 余额、保证金、可交易 symbol 是否满足条件
|
||||
|
||||
### 已知实现事实
|
||||
|
||||
- Binance 在 One-way Mode 下,可能出现 `position side mismatch` 或单边行为
|
||||
- 某些子账户杠杆上限较低,超过限制会直接失败
|
||||
- 这些问题在 `docs/guides/TROUBLESHOOTING.md` 已有明确说明
|
||||
|
||||
## skill_strategy_diagnosis
|
||||
|
||||
### 适用场景
|
||||
|
||||
- 用户说策略没生效
|
||||
- 用户说 prompt 预览和实际不一致
|
||||
- 用户说修改策略后 trader 行为没有变化
|
||||
|
||||
### 优先排查
|
||||
|
||||
1. 当前编辑的是策略模板,还是 trader 的 custom prompt
|
||||
2. 策略是否真的保存成功
|
||||
3. 是否需要重新读取当前配置做对比
|
||||
4. 用户说的“没生效”是指未保存、未绑定,还是运行结果与预期不一致
|
||||
|
||||
### 回答原则
|
||||
|
||||
- 先明确“对象”再排查:strategy template / trader / prompt override
|
||||
- 如果能读取当前保存值,就不要凭印象判断
|
||||
|
||||
## 后续扩展方向
|
||||
|
||||
下一批可以继续补:
|
||||
|
||||
- `skill_balance_and_position_diagnosis`
|
||||
- `skill_market_data_diagnosis`
|
||||
- `skill_prompt_generation_diagnosis`
|
||||
- `skill_strategy_test_run_diagnosis`
|
||||
- `skill_exchange_specific_setup_<exchange>`
|
||||
- `skill_model_provider_setup_<provider>`
|
||||
613
docs/architecture/AGENT_CURRENT_DESIGN.zh-CN.md
Normal file
613
docs/architecture/AGENT_CURRENT_DESIGN.zh-CN.md
Normal file
@@ -0,0 +1,613 @@
|
||||
# NOFXi Agent 当前设计说明
|
||||
|
||||
## 目的
|
||||
|
||||
本文描述当前 NOFXi Agent 的实际设计,而不是早期版本的理想设计。重点回答这些问题:
|
||||
|
||||
- 用户消息从哪里进入
|
||||
- 什么请求会进入 planner
|
||||
- 当前有哪些记忆层
|
||||
- planner 如何生成与执行 plan
|
||||
- tool 现在是怎么设计的
|
||||
- 动态快照和当前引用分别解决什么问题
|
||||
- 为什么某些问题会出现“看起来有历史,但模型还是会追问”
|
||||
|
||||
本文对应的主要实现文件:
|
||||
|
||||
- `agent/agent.go`
|
||||
- `agent/web.go`
|
||||
- `api/agent_routes.go`
|
||||
- `agent/planner_runtime.go`
|
||||
- `agent/execution_state.go`
|
||||
- `agent/memory.go`
|
||||
- `agent/history.go`
|
||||
- `agent/tools.go`
|
||||
|
||||
## 一句话总览
|
||||
|
||||
当前 Agent 的运行模型可以概括为:
|
||||
|
||||
1. 前端把消息发到 `/api/agent/chat/stream`
|
||||
2. 后端把登录用户身份放进 context
|
||||
3. Agent 除 `/clear` 和 `/status` 外,其他消息全部进入 planner
|
||||
4. planner 结合多层记忆、动态快照和 tool schema 生成 plan
|
||||
5. 执行 plan 中的 `tool / reason / ask_user / respond`
|
||||
6. 在执行过程中持续更新执行态、短期原话、长期摘要和当前对象引用
|
||||
|
||||
## 请求入口
|
||||
|
||||
### 前端入口
|
||||
|
||||
前端 Agent 页面在:
|
||||
|
||||
- `web/src/pages/AgentChatPage.tsx`
|
||||
|
||||
当前聊天使用:
|
||||
|
||||
- `POST /api/agent/chat/stream`
|
||||
|
||||
请求体里会传:
|
||||
|
||||
- `message`
|
||||
- `lang`
|
||||
- `user_key`
|
||||
|
||||
### 后端路由入口
|
||||
|
||||
路由注册在:
|
||||
|
||||
- `api/agent_routes.go`
|
||||
|
||||
这里会:
|
||||
|
||||
1. 经过 `authMiddleware`
|
||||
2. 从登录态里取出 `user_id`
|
||||
3. 通过 `agent.WithStoreUserID(...)` 写入 request context
|
||||
|
||||
### Agent Web Handler
|
||||
|
||||
真正的 HTTP handler 在:
|
||||
|
||||
- `agent/web.go`
|
||||
|
||||
主要入口:
|
||||
|
||||
- `HandleChat(...)`
|
||||
- `HandleChatStream(...)`
|
||||
|
||||
再往下进入:
|
||||
|
||||
- `HandleMessageForStoreUser(...)`
|
||||
- `HandleMessageStreamForStoreUser(...)`
|
||||
|
||||
## 最外层分流
|
||||
|
||||
当前外层分流已经被收口。
|
||||
|
||||
在 `agent/agent.go` 中,除了这两个命令之外,其他输入全部交给 planner:
|
||||
|
||||
- `/clear`
|
||||
- `/status`
|
||||
|
||||
也就是说,现在这些都不再在外层直接处理:
|
||||
|
||||
- setup flow
|
||||
- trade confirmation
|
||||
- direct trade regex
|
||||
- 自然语言配置流程
|
||||
- 自然语言策略创建
|
||||
|
||||
这些都统一进入 planner。
|
||||
|
||||
这是当前设计里一个很重要的原则:
|
||||
|
||||
- 外层分流越少,行为边界越清晰
|
||||
- 自然语言理解尽量统一交给 planner + tool
|
||||
|
||||
## 当前的 5 层记忆
|
||||
|
||||
当前不是 3 层,也不是 4 层,而是 5 层:
|
||||
|
||||
1. `chatHistory`
|
||||
2. `TaskState`
|
||||
3. `ExecutionState`
|
||||
4. `CurrentReferences`
|
||||
5. `Persistent Preferences`
|
||||
|
||||
### 1. chatHistory
|
||||
|
||||
定义位置:
|
||||
|
||||
- `agent/history.go`
|
||||
|
||||
作用:
|
||||
|
||||
- 保存最近几轮用户 / assistant 原始消息
|
||||
- 给模型保留最近原话上下文
|
||||
- 为后续摘要成 `TaskState` 提供原始素材
|
||||
|
||||
特点:
|
||||
|
||||
- 只保留短期原话
|
||||
- 内存态
|
||||
- `/clear` 时清空
|
||||
|
||||
适合存:
|
||||
|
||||
- 最近几轮对话原文
|
||||
- 用户的最新措辞
|
||||
- 刚刚的自然语言上下文
|
||||
|
||||
不适合存:
|
||||
|
||||
- 长期真相
|
||||
- 当前外部系统状态
|
||||
- 当前流程精确执行位置
|
||||
|
||||
### 2. TaskState
|
||||
|
||||
定义位置:
|
||||
|
||||
- `agent/memory.go`
|
||||
|
||||
作用:
|
||||
|
||||
- 保存跨轮次仍然有意义的高层摘要
|
||||
- 注入 planner / reasoning / final response
|
||||
|
||||
持久化 key:
|
||||
|
||||
- `agent_task_state_<userID>`
|
||||
|
||||
字段:
|
||||
|
||||
- `CurrentGoal`
|
||||
- `ActiveFlow`
|
||||
- `OpenLoops`
|
||||
- `ImportantFacts`
|
||||
- `LastDecision`
|
||||
- `UpdatedAt`
|
||||
|
||||
适合存:
|
||||
|
||||
- 当前高层目标
|
||||
- 跨轮次仍然成立的未闭环事项
|
||||
- 关键事实
|
||||
- 最近一次重要决策及其原因
|
||||
|
||||
不适合存:
|
||||
|
||||
- step 级待办
|
||||
- “下一步调用哪个 tool”
|
||||
- 动态余额、持仓、配置存在性
|
||||
- 任何可以通过 tool 重新读取的实时状态
|
||||
|
||||
### 3. ExecutionState
|
||||
|
||||
定义位置:
|
||||
|
||||
- `agent/execution_state.go`
|
||||
|
||||
作用:
|
||||
|
||||
- 保存当前 plan 的执行态
|
||||
- 支持 `ask_user` 之后继续执行
|
||||
- 保存 plan、当前步骤、执行日志、等待状态等
|
||||
|
||||
持久化 key:
|
||||
|
||||
- `agent_execution_state_<userID>`
|
||||
|
||||
当前关键字段:
|
||||
|
||||
- `SessionID`
|
||||
- `Goal`
|
||||
- `Status`
|
||||
- `PlanID`
|
||||
- `Steps`
|
||||
- `CurrentStepID`
|
||||
- `DynamicSnapshots`
|
||||
- `ExecutionLog`
|
||||
- `SummaryNotes`
|
||||
- `Waiting`
|
||||
- `CurrentReferences`
|
||||
- `FinalAnswer`
|
||||
- `LastError`
|
||||
|
||||
### 4. CurrentReferences
|
||||
|
||||
定义位置:
|
||||
|
||||
- `agent/execution_state.go`
|
||||
|
||||
作用:
|
||||
|
||||
- 记录当前对话里“这个 / 那个 / 刚才那个”到底指的是谁
|
||||
|
||||
当前支持的引用对象:
|
||||
|
||||
- `strategy`
|
||||
- `trader`
|
||||
- `model`
|
||||
- `exchange`
|
||||
|
||||
这是为了解决一种常见问题:
|
||||
|
||||
- 用户明明前一轮刚说过“激进策略”
|
||||
- 下一轮说“改一下这个策略”
|
||||
- 如果没有结构化引用,模型虽然有聊天历史,也容易重新追问
|
||||
|
||||
`CurrentReferences` 不是系统状态快照,而是:
|
||||
|
||||
- 当前对话焦点对象
|
||||
- 当前代词绑定对象
|
||||
|
||||
### 5. Persistent Preferences
|
||||
|
||||
对应工具:
|
||||
|
||||
- `get_preferences`
|
||||
- `manage_preferences`
|
||||
|
||||
作用:
|
||||
|
||||
- 保存用户长期偏好
|
||||
|
||||
适合存:
|
||||
|
||||
- 默认中文回复
|
||||
- 偏好激进风格
|
||||
- 更关注 BTC / ETH
|
||||
- 不喜欢高频
|
||||
- 每天固定时间简报
|
||||
|
||||
它和 `TaskState` 的区别是:
|
||||
|
||||
- `TaskState` 偏向当前任务摘要
|
||||
- `Persistent Preferences` 偏向长期用户画像
|
||||
|
||||
## DynamicSnapshots 是什么
|
||||
|
||||
`DynamicSnapshots` 是当前真实系统状态的快照。
|
||||
|
||||
它不是历史,也不是长期记忆,而是 planner 在规划前或执行中插入的“当前事实”。
|
||||
|
||||
当前会进入快照的典型信息包括:
|
||||
|
||||
- 当前模型配置列表
|
||||
- 当前交易所配置列表
|
||||
- 当前策略列表
|
||||
- 当前 trader 列表
|
||||
- 当前余额
|
||||
- 当前持仓
|
||||
- 最近交易历史
|
||||
|
||||
作用:
|
||||
|
||||
- 防止 planner 盲信旧结论
|
||||
- 避免“之前没配置,现在其实已经配好了却还说没有”
|
||||
- 避免“之前余额是 A,现在拿旧 observation 继续回答”
|
||||
|
||||
一句话:
|
||||
|
||||
- `DynamicSnapshots` = 当前世界里真实有什么
|
||||
|
||||
## CurrentReferences 和 DynamicSnapshots 的区别
|
||||
|
||||
这两个容易混淆,但职责完全不同。
|
||||
|
||||
`DynamicSnapshots`:
|
||||
|
||||
- 当前系统状态快照
|
||||
- 是候选集合 / 当前事实
|
||||
- 例如当前有两个策略:`激进`、`新策略`
|
||||
|
||||
`CurrentReferences`:
|
||||
|
||||
- 当前对话焦点对象
|
||||
- 是“这个”到底指谁
|
||||
- 例如用户现在说的“这个策略”就是 `激进`
|
||||
|
||||
可以这样理解:
|
||||
|
||||
- `DynamicSnapshots` 是地图
|
||||
- `CurrentReferences` 是你手指现在指着地图上的哪个点
|
||||
|
||||
## Planner 的输入
|
||||
|
||||
planner 主逻辑在:
|
||||
|
||||
- `agent/planner_runtime.go`
|
||||
|
||||
生成计划时,当前会把这些东西一起送给模型:
|
||||
|
||||
- 当前用户请求
|
||||
- tool schema
|
||||
- `Persistent Preferences`
|
||||
- `TaskState`
|
||||
- `ExecutionState`
|
||||
- `Resume context`
|
||||
- `Structured waiting state`
|
||||
- `Observation context`
|
||||
|
||||
其中 observation context 不是旧版单数组,而是分层后的:
|
||||
|
||||
- `dynamic_snapshots`
|
||||
- `execution_log`
|
||||
- `summary_notes`
|
||||
|
||||
## Plan 的结构
|
||||
|
||||
当前 planner 只允许这 4 类 step:
|
||||
|
||||
- `tool`
|
||||
- `reason`
|
||||
- `ask_user`
|
||||
- `respond`
|
||||
|
||||
这意味着现在的 Agent 不是一个“自由发挥的回复器”,而是:
|
||||
|
||||
- 先规划
|
||||
- 再执行步骤
|
||||
- 必要时重规划
|
||||
|
||||
## 步骤执行流程
|
||||
|
||||
`executePlan(...)` 的核心逻辑是:
|
||||
|
||||
1. 找下一个 pending step
|
||||
2. 标记 step 为 running
|
||||
3. 执行对应类型
|
||||
4. 写回 `ExecutionState`
|
||||
5. 必要时触发 replanning
|
||||
|
||||
不同 step 类型行为如下:
|
||||
|
||||
### tool
|
||||
|
||||
- 调内部 tool
|
||||
- 把结果写入 `ExecutionLog`
|
||||
- 根据结果更新 `CurrentReferences`
|
||||
- 必要时触发 replanner
|
||||
|
||||
### reason
|
||||
|
||||
- 发起一次短 reasoning 调用
|
||||
- 生成一段简短中间推理
|
||||
- 写入 `ExecutionLog`
|
||||
|
||||
### ask_user
|
||||
|
||||
- 进入 `waiting_user`
|
||||
- 保存 `WaitingState`
|
||||
- 把问题直接回给用户
|
||||
|
||||
### respond
|
||||
|
||||
- 生成最终回答
|
||||
- 标记当前执行完成
|
||||
|
||||
## WaitingState 是什么
|
||||
|
||||
`WaitingState` 用来解决:
|
||||
|
||||
- 用户回复 `是`
|
||||
- 用户回复 `继续`
|
||||
- 用户回复 `那个就行`
|
||||
|
||||
这类短回复如果没有结构化等待状态,很容易丢上下文。
|
||||
|
||||
当前字段包括:
|
||||
|
||||
- `Question`
|
||||
- `Intent`
|
||||
- `PendingFields`
|
||||
- `ConfirmationTarget`
|
||||
- `CreatedAt`
|
||||
|
||||
它的作用是:
|
||||
|
||||
- 告诉 planner 上一轮到底在等什么
|
||||
- 让这轮短回复更容易被理解成“对上一问的回答”
|
||||
|
||||
## CurrentReferences 如何更新
|
||||
|
||||
当前是双路径更新:
|
||||
|
||||
### 1. 用户消息命中对象名时更新
|
||||
|
||||
如果用户说:
|
||||
|
||||
- `修改激进策略`
|
||||
- `停止 lky`
|
||||
- `用 DeepSeek`
|
||||
|
||||
系统会去当前用户的策略 / trader / model / exchange 列表里尝试匹配名称或 ID。
|
||||
|
||||
匹配成功后,更新 `CurrentReferences`。
|
||||
|
||||
### 2. tool 成功返回对象时更新
|
||||
|
||||
比如:
|
||||
|
||||
- `manage_strategy(create/update/activate)`
|
||||
- `manage_trader(create/update)`
|
||||
- `manage_model_config(update)`
|
||||
- `manage_exchange_config(update)`
|
||||
|
||||
只要 tool 返回了具体对象,系统就会把对应 ID / name 写回当前引用。
|
||||
|
||||
## Tool 设计
|
||||
|
||||
当前 tool 是“资源型 tool”设计,不是“页面动作型 tool”。
|
||||
|
||||
### 当前主要工具
|
||||
|
||||
配置资源:
|
||||
|
||||
- `get_exchange_configs`
|
||||
- `manage_exchange_config`
|
||||
- `get_model_configs`
|
||||
- `manage_model_config`
|
||||
|
||||
策略资源:
|
||||
|
||||
- `get_strategies`
|
||||
- `manage_strategy`
|
||||
|
||||
trader 资源:
|
||||
|
||||
- `manage_trader`
|
||||
|
||||
交易 / 查询资源:
|
||||
|
||||
- `search_stock`
|
||||
- `execute_trade`
|
||||
- `get_positions`
|
||||
- `get_balance`
|
||||
- `get_market_price`
|
||||
- `get_trade_history`
|
||||
|
||||
### 为什么这么设计
|
||||
|
||||
优点:
|
||||
|
||||
- tool schema 稳定
|
||||
- 行为边界清晰
|
||||
- planner 更容易学会
|
||||
- 资源增删改查统一
|
||||
|
||||
当前 `manage_strategy` 支持:
|
||||
|
||||
- `list`
|
||||
- `get_default_config`
|
||||
- `create`
|
||||
- `update`
|
||||
- `delete`
|
||||
- `activate`
|
||||
- `duplicate`
|
||||
|
||||
当前 `manage_trader` 支持:
|
||||
|
||||
- `list`
|
||||
- `create`
|
||||
- `update`
|
||||
- `delete`
|
||||
- `start`
|
||||
- `stop`
|
||||
|
||||
## 为什么“创建策略”不该默认依赖交易所和模型
|
||||
|
||||
当前设计里,策略模板应该是独立资源:
|
||||
|
||||
- `strategy`
|
||||
|
||||
而运行态对象是:
|
||||
|
||||
- `trader`
|
||||
|
||||
更合理的边界是:
|
||||
|
||||
- 创建策略模板:用 `manage_strategy`
|
||||
- 把策略跑起来:用 `manage_trader`
|
||||
|
||||
也就是说:
|
||||
|
||||
- 策略不默认依赖交易所和模型
|
||||
- 只有当用户要求“运行 / 部署 / 创建 trader”时,才需要进一步关联 exchange / model / trader
|
||||
|
||||
## 当前一个完整例子
|
||||
|
||||
用户输入:
|
||||
|
||||
`帮我创建一个新的激进策略模板,名字就叫激进。创建完后,再把这个策略绑定到 trader lky。`
|
||||
|
||||
当前大致流程:
|
||||
|
||||
1. 前端请求 `/api/agent/chat/stream`
|
||||
2. 后端注入 `store_user_id`
|
||||
3. Agent 进入 planner
|
||||
4. planner 刷新动态快照:
|
||||
- 当前策略
|
||||
- 当前 trader
|
||||
5. 生成 plan,例如:
|
||||
- `get_strategies`
|
||||
- `manage_strategy(create)`
|
||||
- `manage_trader(update)`
|
||||
- `respond`
|
||||
6. 执行 `manage_strategy(create)` 后:
|
||||
- 写入 `ExecutionLog`
|
||||
- 更新 `CurrentReferences.strategy`
|
||||
7. 执行 `manage_trader(update)` 时:
|
||||
- 直接使用刚创建策略的 ID
|
||||
8. 输出最终回复
|
||||
|
||||
如果此后用户继续说:
|
||||
|
||||
`把这个策略的 prompt 改激进一点`
|
||||
|
||||
系统会优先从 `CurrentReferences.strategy` 理解“这个策略”。
|
||||
|
||||
## 为什么看起来“有历史”,模型还是会追问
|
||||
|
||||
因为“有聊天历史”不等于“有结构化对象绑定”。
|
||||
|
||||
如果没有 `CurrentReferences`:
|
||||
|
||||
- 模型只能依赖原话文本推断“这个策略”是谁
|
||||
- 一旦中间插入多条消息,或者有多个候选策略
|
||||
- 就容易重新追问
|
||||
|
||||
所以当前设计里,`CurrentReferences` 是补齐这一块的关键。
|
||||
|
||||
## 当前已知限制
|
||||
|
||||
### 1. 外层虽然已经大幅收口,但仍然不是纯 graph runtime
|
||||
|
||||
现在比之前更统一,但整体仍然是:
|
||||
|
||||
- Agent 主入口
|
||||
- Planner
|
||||
- Tool 执行
|
||||
|
||||
而不是完整 node-graph 引擎。
|
||||
|
||||
### 2. ExecutionState 仍然是按 userID 单槽位
|
||||
|
||||
这意味着:
|
||||
|
||||
- 同一用户的多个并行任务仍然可能相互影响
|
||||
|
||||
更彻底的方向应该是:
|
||||
|
||||
- 按 thread / session 多实例存储
|
||||
|
||||
### 3. CurrentReferences 目前还是轻量实现
|
||||
|
||||
当前只覆盖:
|
||||
|
||||
- strategy
|
||||
- trader
|
||||
- model
|
||||
- exchange
|
||||
|
||||
后面如果要更强,需要考虑:
|
||||
|
||||
- 多候选冲突消解
|
||||
- 昵称映射
|
||||
- 跨更长会话的稳定实体绑定
|
||||
|
||||
## 当前设计的核心思想
|
||||
|
||||
一句话总结:
|
||||
|
||||
- `chatHistory` 记原话
|
||||
- `Persistent Preferences` 记长期偏好
|
||||
- `TaskState` 记高层摘要
|
||||
- `ExecutionState` 记当前流程
|
||||
- `DynamicSnapshots` 记当前事实
|
||||
- `CurrentReferences` 记当前指代对象
|
||||
- `planner` 决定步骤
|
||||
- `tools` 执行落地动作
|
||||
|
||||
这就是当前 NOFXi Agent 的实际运行设计。
|
||||
454
docs/architecture/AGENT_MEMORY_AND_PLANNING.md
Normal file
454
docs/architecture/AGENT_MEMORY_AND_PLANNING.md
Normal file
@@ -0,0 +1,454 @@
|
||||
# NOFXi Agent Memory And Planning Design
|
||||
|
||||
## Purpose
|
||||
|
||||
This document explains how the current NOFXi agent handles:
|
||||
|
||||
- short-term conversation memory
|
||||
- durable task memory
|
||||
- durable execution / planning state
|
||||
- planner execution and replanning
|
||||
- state reset and resume behavior
|
||||
|
||||
The implementation described here is primarily in:
|
||||
|
||||
- `agent/history.go`
|
||||
- `agent/memory.go`
|
||||
- `agent/execution_state.go`
|
||||
- `agent/planner_runtime.go`
|
||||
- `agent/agent.go`
|
||||
|
||||
## High-Level Model
|
||||
|
||||
The current agent uses three different layers of state:
|
||||
|
||||
1. `chatHistory`
|
||||
Recent in-memory user/assistant turns for the live conversation.
|
||||
|
||||
2. `TaskState`
|
||||
Durable summarized context that should survive beyond recent turns.
|
||||
|
||||
3. `ExecutionState`
|
||||
Durable workflow state for the currently running or recently blocked plan.
|
||||
|
||||
These three layers serve different purposes and should not be treated as the same thing.
|
||||
|
||||
## State Layers
|
||||
|
||||
### 1. `chatHistory`
|
||||
|
||||
Defined in `agent/history.go`.
|
||||
|
||||
Role:
|
||||
|
||||
- stores recent `user` / `assistant` messages in memory
|
||||
- keyed by `userID`
|
||||
- used as short-term conversational context
|
||||
- acts as the source material for later compression into `TaskState`
|
||||
|
||||
Characteristics:
|
||||
|
||||
- in-memory only
|
||||
- capped by `maxTurns`
|
||||
- cleared by `/clear`
|
||||
- not suitable as durable truth
|
||||
|
||||
Typical contents:
|
||||
|
||||
- the last few user questions
|
||||
- the last few assistant replies
|
||||
- temporary conversational wording
|
||||
|
||||
### 2. `TaskState`
|
||||
|
||||
Defined in `agent/memory.go`.
|
||||
|
||||
Role:
|
||||
|
||||
- stores durable, structured, non-derivable context
|
||||
- persisted through `system_config`
|
||||
- injected into planning and reasoning prompts
|
||||
|
||||
Storage key:
|
||||
|
||||
- `agent_task_state_<userID>`
|
||||
|
||||
Fields:
|
||||
|
||||
- `CurrentGoal`
|
||||
- `ActiveFlow`
|
||||
- `OpenLoops`
|
||||
- `ImportantFacts`
|
||||
- `LastDecision`
|
||||
- `UpdatedAt`
|
||||
|
||||
Intended contents:
|
||||
|
||||
- user goal that still matters across turns
|
||||
- high-level unresolved issues that still matter across turns
|
||||
- facts that tools cannot cheaply re-fetch
|
||||
- latest important decision summary
|
||||
|
||||
Explicitly not intended for:
|
||||
|
||||
- step-level pending items such as "wait for API key"
|
||||
- execution actions such as "call get_exchange_configs"
|
||||
- live balances
|
||||
- current positions
|
||||
- current market prices
|
||||
- mutable configuration availability
|
||||
|
||||
Those should be checked from tools at planning time instead of being trusted from old summaries.
|
||||
|
||||
### 3. `ExecutionState`
|
||||
|
||||
Defined in `agent/execution_state.go`.
|
||||
|
||||
Role:
|
||||
|
||||
- stores the current execution workflow
|
||||
- allows the agent to resume after `ask_user`
|
||||
- persists plan steps, observations, and completion status
|
||||
|
||||
Storage key:
|
||||
|
||||
- `agent_execution_state_<userID>`
|
||||
|
||||
Fields:
|
||||
|
||||
- `SessionID`
|
||||
- `UserID`
|
||||
- `Goal`
|
||||
- `Status`
|
||||
- `PlanID`
|
||||
- `Steps`
|
||||
- `CurrentStepID`
|
||||
- `Observations`
|
||||
- `FinalAnswer`
|
||||
- `LastError`
|
||||
- `UpdatedAt`
|
||||
|
||||
This is the planner's working state, not a general memory store.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Request Entry
|
||||
|
||||
Entry points:
|
||||
|
||||
- `HandleMessage(...)`
|
||||
- `HandleMessageStream(...)`
|
||||
|
||||
Flow:
|
||||
|
||||
1. user message enters `agent`
|
||||
2. slash commands and explicit direct branches are handled first
|
||||
3. all other requests go into planner flow via `thinkAndAct(...)` / `thinkAndActStream(...)`
|
||||
|
||||
### Planner Flow
|
||||
|
||||
The planner pipeline in `agent/planner_runtime.go` is:
|
||||
|
||||
1. append user message into `chatHistory`
|
||||
2. emit `planning` SSE event
|
||||
3. load `ExecutionState`
|
||||
4. optionally reset stale `ExecutionState`
|
||||
5. optionally refresh dynamic configuration snapshots
|
||||
6. create a fresh execution plan with the LLM
|
||||
7. execute steps one by one
|
||||
8. persist `ExecutionState` after important transitions
|
||||
9. append assistant answer into `chatHistory`
|
||||
10. maybe compress old conversation into `TaskState`
|
||||
|
||||
## Short-Term vs Durable Memory
|
||||
|
||||
### What lives in `chatHistory`
|
||||
|
||||
Good fits:
|
||||
|
||||
- raw recent messages
|
||||
- conversational wording
|
||||
- latest assistant phrasing
|
||||
|
||||
Bad fits:
|
||||
|
||||
- long-lived truths
|
||||
- current external system state
|
||||
|
||||
### What lives in `TaskState`
|
||||
|
||||
Good fits:
|
||||
|
||||
- durable goal
|
||||
- high-level unfinished work that remains relevant across turns
|
||||
- important facts the user stated
|
||||
- previous decisions and why they were made
|
||||
|
||||
Bad fits:
|
||||
|
||||
- pending steps inside the current plan
|
||||
- execution-level reminders such as "wait for a field" or "call a tool"
|
||||
- old conclusions about whether tools exist
|
||||
- old conclusions about whether model/exchange config is present
|
||||
- live operational state that can change outside the chat
|
||||
|
||||
### What lives in `ExecutionState`
|
||||
|
||||
Good fits:
|
||||
|
||||
- current plan steps
|
||||
- observations from tool calls
|
||||
- blocked-on-user-input status
|
||||
- exact current workflow state
|
||||
- step-level pending work and block reasons
|
||||
|
||||
Bad fits:
|
||||
|
||||
- evergreen user profile
|
||||
- long-term semantic memory
|
||||
|
||||
## Planning Logic
|
||||
|
||||
### Plan Creation
|
||||
|
||||
`createExecutionPlan(...)` sends the following into the planner model:
|
||||
|
||||
- available tool definitions
|
||||
- persistent preferences
|
||||
- `TaskState` context
|
||||
- `ExecutionState` JSON
|
||||
- current user request
|
||||
|
||||
The planner must return JSON only with step types:
|
||||
|
||||
- `tool`
|
||||
- `reason`
|
||||
- `ask_user`
|
||||
- `respond`
|
||||
|
||||
### Step Execution
|
||||
|
||||
`executePlan(...)` executes the plan loop:
|
||||
|
||||
- `tool`
|
||||
call tool and append observation
|
||||
- `reason`
|
||||
run reasoning sub-call and append observation
|
||||
- `ask_user`
|
||||
save `waiting_user` state and return question
|
||||
- `respond`
|
||||
generate final answer and mark completed
|
||||
|
||||
After each completed step, `replanAfterStep(...)` may:
|
||||
|
||||
- continue
|
||||
- replace remaining steps
|
||||
- ask user
|
||||
- finish
|
||||
|
||||
## Resume Behavior
|
||||
|
||||
When `ExecutionState.Status == waiting_user`, the next user turn is treated as a reply to the pending question.
|
||||
|
||||
Current safeguards:
|
||||
|
||||
- latest asked question is extracted from the stored plan
|
||||
- the user reply is appended as a `user_reply` observation
|
||||
- planner prompt receives explicit `Resume context`
|
||||
|
||||
This prevents short replies like `是` from being misread as unrelated fresh intents as often as before.
|
||||
|
||||
## Dynamic State Refresh
|
||||
|
||||
Configuration and trader management requests are dynamic by nature. Their truth can change outside the current chat, for example:
|
||||
|
||||
- user configures exchange in the UI
|
||||
- user adds model in another tab
|
||||
- user creates trader elsewhere
|
||||
|
||||
Because of that, configuration/trader requests should not trust stale model conclusions.
|
||||
|
||||
Current protection in `planner_runtime.go`:
|
||||
|
||||
- detects config / trader intent with `isConfigOrTraderIntent(...)`
|
||||
- clears `TaskState` context from the planner prompt for these requests
|
||||
- refreshes `ExecutionState.Observations` with fresh snapshots from:
|
||||
- `toolGetModelConfigs(...)`
|
||||
- `toolGetExchangeConfigs(...)`
|
||||
- `toolListTraders(...)`
|
||||
|
||||
This makes the planner rely more on current system state and less on older narrative memory.
|
||||
|
||||
## Reset Strategy
|
||||
|
||||
The system currently resets or weakens stale execution state when:
|
||||
|
||||
- user says retry-like phrases such as `再试`, `继续`, `try again`, `continue`
|
||||
- request is config / trader related and old execution state is failed / completed / waiting
|
||||
|
||||
Reset scope:
|
||||
|
||||
- `ExecutionState` may be cleared
|
||||
- `TaskState` is not globally deleted, but it is intentionally ignored for config/trader planning
|
||||
|
||||
Manual reset:
|
||||
|
||||
- `/clear`
|
||||
|
||||
This clears:
|
||||
|
||||
- short-term chat history
|
||||
- task state
|
||||
- execution state
|
||||
|
||||
## Compression Design
|
||||
|
||||
`maybeCompressHistory(...)` moves older short-term chat content into `TaskState` when:
|
||||
|
||||
- recent message count exceeds the configured window
|
||||
- estimated token count exceeds the threshold
|
||||
|
||||
Compression strategy:
|
||||
|
||||
1. keep recent conversation in `chatHistory`
|
||||
2. summarize older turns into structured `TaskState`
|
||||
3. persist new `TaskState`
|
||||
4. replace `chatHistory` with recent slice
|
||||
|
||||
Important design rule:
|
||||
|
||||
- `TaskState` should keep durable context only
|
||||
- it should not become a stale copy of mutable operational state
|
||||
|
||||
## Current Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
U[User Message] --> A[HandleMessage / HandleMessageStream]
|
||||
A --> B{Direct command?}
|
||||
B -->|Yes| C[Direct branch or slash command]
|
||||
B -->|No| D[thinkAndAct / thinkAndActStream]
|
||||
|
||||
D --> E[Append user turn to chatHistory]
|
||||
D --> F[Load ExecutionState]
|
||||
F --> G{waiting_user?}
|
||||
G -->|Yes| H[Attach user_reply observation]
|
||||
G -->|No| I[Create fresh ExecutionState]
|
||||
|
||||
H --> J[Refresh dynamic snapshots if config/trader intent]
|
||||
I --> J
|
||||
J --> K[createExecutionPlan via LLM]
|
||||
K --> L[Execution plan]
|
||||
L --> M[executePlan loop]
|
||||
|
||||
M --> N[tool step]
|
||||
M --> O[reason step]
|
||||
M --> P[ask_user step]
|
||||
M --> Q[respond step]
|
||||
|
||||
N --> R[Append Observation]
|
||||
O --> R
|
||||
R --> S[replanAfterStep]
|
||||
S --> M
|
||||
|
||||
P --> T[Persist waiting_user ExecutionState]
|
||||
T --> UQ[Return question to user]
|
||||
|
||||
Q --> V[Persist completed ExecutionState]
|
||||
V --> W[Append assistant turn to chatHistory]
|
||||
W --> X[maybeCompressHistory]
|
||||
X --> Y[Persist TaskState]
|
||||
Y --> Z[Final response]
|
||||
```
|
||||
|
||||
## Memory Relationship Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CH[chatHistory\nin-memory\nrecent turns]
|
||||
TS[TaskState\npersisted summary\nsystem_config]
|
||||
ES[ExecutionState\npersisted workflow\nsystem_config]
|
||||
PL[Planner Prompt]
|
||||
|
||||
CH -->|recent raw turns| PL
|
||||
ES -->|current workflow JSON| PL
|
||||
TS -->|durable structured context| PL
|
||||
|
||||
CH -->|old turns compressed| TS
|
||||
PL -->|plan / observations / status| ES
|
||||
```
|
||||
|
||||
## State Transition Diagram
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> planning
|
||||
planning --> running: plan created
|
||||
running --> waiting_user: ask_user step
|
||||
waiting_user --> planning: user replies
|
||||
running --> completed: respond step finished
|
||||
running --> failed: step error
|
||||
failed --> planning: retry / continue / config-trader reset
|
||||
completed --> planning: new relevant request or retry flow
|
||||
```
|
||||
|
||||
## Known Design Tradeoffs
|
||||
|
||||
### Strengths
|
||||
|
||||
- separates short-term chat from durable task summary
|
||||
- allows blocked flows to resume
|
||||
- supports replanning after every meaningful step
|
||||
- can recover from stale assumptions better for dynamic config/trader requests
|
||||
|
||||
### Weaknesses
|
||||
|
||||
- `TaskState` is still summary-driven, so summarization quality matters
|
||||
- planner still depends on model compliance for some transitions
|
||||
- `ExecutionState` is single-track per user, not multiple concurrent workflows
|
||||
- config/trader intent detection is heuristic and keyword-based
|
||||
|
||||
## Practical Guidance
|
||||
|
||||
### When to trust `TaskState`
|
||||
|
||||
Trust it for:
|
||||
|
||||
- user intent continuity
|
||||
- open loops
|
||||
- durable facts
|
||||
|
||||
Do not trust it for:
|
||||
|
||||
- whether current exchange/model/trader config exists now
|
||||
- whether a specific operational action is currently possible
|
||||
|
||||
### When to trust `ExecutionState`
|
||||
|
||||
Trust it for:
|
||||
|
||||
- current plan continuity
|
||||
- exact blocked step
|
||||
- latest observation chain
|
||||
|
||||
Do not trust it blindly when:
|
||||
|
||||
- user has changed configuration outside the chat
|
||||
- the system capabilities changed after deployment
|
||||
|
||||
### When to fetch live state again
|
||||
|
||||
Always prefer fresh tool snapshots before answering about:
|
||||
|
||||
- existing model configs
|
||||
- existing exchange configs
|
||||
- existing traders
|
||||
- whether trader creation can proceed
|
||||
|
||||
## Suggested Future Improvements
|
||||
|
||||
- add workflow versioning so capability changes invalidate stale `ExecutionState`
|
||||
- separate `waiting_user_confirmation` from generic `waiting_user`
|
||||
- introduce code-level handling for short confirmations such as `是`, `好`, `继续`
|
||||
- move dynamic state refresh from heuristic to explicit planner preflight stage
|
||||
- support multiple concurrent execution sessions per user if needed
|
||||
453
docs/architecture/AGENT_MEMORY_AND_PLANNING.zh-CN.md
Normal file
453
docs/architecture/AGENT_MEMORY_AND_PLANNING.zh-CN.md
Normal file
@@ -0,0 +1,453 @@
|
||||
# NOFXi Agent 记忆与规划设计
|
||||
|
||||
## 目的
|
||||
|
||||
本文说明当前 NOFXi agent 是如何处理以下能力的:
|
||||
|
||||
- 短期对话记忆
|
||||
- 持久化任务记忆
|
||||
- 持久化执行态 / 规划态
|
||||
- planner 的执行与重规划
|
||||
- 状态重置与恢复
|
||||
|
||||
本文主要对应以下实现文件:
|
||||
|
||||
- `agent/history.go`
|
||||
- `agent/memory.go`
|
||||
- `agent/execution_state.go`
|
||||
- `agent/planner_runtime.go`
|
||||
- `agent/agent.go`
|
||||
|
||||
## 总体模型
|
||||
|
||||
当前 agent 使用三层不同的状态:
|
||||
|
||||
1. `chatHistory`
|
||||
用于保存当前会话最近几轮的原始用户/助手对话,驻留内存。
|
||||
|
||||
2. `TaskState`
|
||||
用于保存跨轮次仍然有价值的结构化摘要,持久化存储。
|
||||
|
||||
3. `ExecutionState`
|
||||
用于保存当前规划流程的执行态,支持流程中断后的继续执行。
|
||||
|
||||
这三层职责不同,不能混为一谈。
|
||||
|
||||
## 三层状态
|
||||
|
||||
### 1. `chatHistory`
|
||||
|
||||
定义位置:`agent/history.go`
|
||||
|
||||
作用:
|
||||
|
||||
- 按 `userID` 保存最近的 `user` / `assistant` 消息
|
||||
- 作为短期对话上下文
|
||||
- 作为后续压缩进 `TaskState` 的原始素材
|
||||
|
||||
特性:
|
||||
|
||||
- 仅在内存中存在
|
||||
- 有 `maxTurns` 上限
|
||||
- `/clear` 时会清空
|
||||
- 不适合作为长期真相来源
|
||||
|
||||
典型内容:
|
||||
|
||||
- 最近几轮用户问题
|
||||
- 最近几轮助手回答
|
||||
- 临时措辞与上下文表达
|
||||
|
||||
### 2. `TaskState`
|
||||
|
||||
定义位置:`agent/memory.go`
|
||||
|
||||
作用:
|
||||
|
||||
- 保存持久化、结构化、不可轻易从工具重新推导出的上下文
|
||||
- 通过 `system_config` 持久化
|
||||
- 注入到 planner / reasoning prompt 中
|
||||
|
||||
存储 key:
|
||||
|
||||
- `agent_task_state_<userID>`
|
||||
|
||||
字段:
|
||||
|
||||
- `CurrentGoal`
|
||||
- `ActiveFlow`
|
||||
- `OpenLoops`
|
||||
- `ImportantFacts`
|
||||
- `LastDecision`
|
||||
- `UpdatedAt`
|
||||
|
||||
适合存放:
|
||||
|
||||
- 当前仍有效的用户目标
|
||||
- 跨轮次仍然成立的高层未闭环问题
|
||||
- 无法简单通过工具重新读取的重要事实
|
||||
- 最近一次关键决策及原因
|
||||
|
||||
不适合存放:
|
||||
|
||||
- “等用户提供 API Key” 这类 step 级待办
|
||||
- “调用 get_exchange_configs” 这类执行动作
|
||||
- 实时余额
|
||||
- 当前持仓
|
||||
- 当前行情价格
|
||||
- 是否存在某个配置这类会变化的状态
|
||||
|
||||
这些动态信息应该在规划阶段通过工具重新检查,而不是相信旧摘要。
|
||||
|
||||
### 3. `ExecutionState`
|
||||
|
||||
定义位置:`agent/execution_state.go`
|
||||
|
||||
作用:
|
||||
|
||||
- 保存当前执行中的工作流状态
|
||||
- 支持 `ask_user` 之后恢复执行
|
||||
- 持久化保存计划步骤、观察结果和最终状态
|
||||
|
||||
存储 key:
|
||||
|
||||
- `agent_execution_state_<userID>`
|
||||
|
||||
字段:
|
||||
|
||||
- `SessionID`
|
||||
- `UserID`
|
||||
- `Goal`
|
||||
- `Status`
|
||||
- `PlanID`
|
||||
- `Steps`
|
||||
- `CurrentStepID`
|
||||
- `Observations`
|
||||
- `FinalAnswer`
|
||||
- `LastError`
|
||||
- `UpdatedAt`
|
||||
|
||||
它是 planner 的“工作态”,不是通用记忆仓库。
|
||||
|
||||
## 数据流
|
||||
|
||||
### 请求入口
|
||||
|
||||
入口函数:
|
||||
|
||||
- `HandleMessage(...)`
|
||||
- `HandleMessageStream(...)`
|
||||
|
||||
流程:
|
||||
|
||||
1. 用户消息进入 `agent`
|
||||
2. 优先处理 slash command 和显式直达分支
|
||||
3. 其余请求进入 planner 流程:`thinkAndAct(...)` / `thinkAndActStream(...)`
|
||||
|
||||
### Planner 主流程
|
||||
|
||||
`agent/planner_runtime.go` 中的 planner 管线如下:
|
||||
|
||||
1. 把用户消息加入 `chatHistory`
|
||||
2. 发出 `planning` SSE 事件
|
||||
3. 加载 `ExecutionState`
|
||||
4. 视情况重置过期的 `ExecutionState`
|
||||
5. 视情况刷新动态配置快照
|
||||
6. 调用 LLM 生成新的执行计划
|
||||
7. 按步骤执行计划
|
||||
8. 在关键状态变化后持久化 `ExecutionState`
|
||||
9. 把助手回答加入 `chatHistory`
|
||||
10. 视情况把旧对话压缩进 `TaskState`
|
||||
|
||||
## 短期记忆 vs 持久记忆
|
||||
|
||||
### `chatHistory` 里应该放什么
|
||||
|
||||
适合:
|
||||
|
||||
- 最近原始消息
|
||||
- 对话措辞
|
||||
- 最近一轮助手的表达方式
|
||||
|
||||
不适合:
|
||||
|
||||
- 长期真相
|
||||
- 外部系统当前状态
|
||||
|
||||
### `TaskState` 里应该放什么
|
||||
|
||||
适合:
|
||||
|
||||
- 持续目标
|
||||
- 跨轮次仍有意义的高层未闭环事项
|
||||
- 用户明确讲过的重要事实
|
||||
- 历史关键决策和原因
|
||||
|
||||
不适合:
|
||||
|
||||
- 当前 plan 中尚未执行的步骤
|
||||
- “等待某个字段”“调用某个 tool” 这类执行级待办
|
||||
- “系统有没有这个工具” 这种过时结论
|
||||
- “当前有没有模型/交易所配置” 这种可变化状态
|
||||
- 可以通过工具重新查询到的动态状态
|
||||
|
||||
### `ExecutionState` 里应该放什么
|
||||
|
||||
适合:
|
||||
|
||||
- 当前计划步骤
|
||||
- 工具调用观察结果
|
||||
- 当前是否卡在等用户补充信息
|
||||
- 当前工作流的精确执行位置
|
||||
- step 级待办和阻塞原因
|
||||
|
||||
不适合:
|
||||
|
||||
- 长期用户画像
|
||||
- 通用长期语义记忆
|
||||
|
||||
## 规划逻辑
|
||||
|
||||
### 计划生成
|
||||
|
||||
`createExecutionPlan(...)` 会把以下信息送给 planner 模型:
|
||||
|
||||
- 当前可用 tool 定义
|
||||
- 持久化用户偏好
|
||||
- `TaskState` 上下文
|
||||
- `ExecutionState` JSON
|
||||
- 当前用户请求
|
||||
|
||||
planner 必须返回 JSON,且步骤类型只能是:
|
||||
|
||||
- `tool`
|
||||
- `reason`
|
||||
- `ask_user`
|
||||
- `respond`
|
||||
|
||||
### 步骤执行
|
||||
|
||||
`executePlan(...)` 的执行循环如下:
|
||||
|
||||
- `tool`
|
||||
调用工具并写入 observation
|
||||
- `reason`
|
||||
发起 reasoning 子调用并写入 observation
|
||||
- `ask_user`
|
||||
保存 `waiting_user` 状态并把问题返回给用户
|
||||
- `respond`
|
||||
生成最终回答并标记完成
|
||||
|
||||
每个步骤结束后,`replanAfterStep(...)` 还可以决定:
|
||||
|
||||
- continue
|
||||
- replace_remaining
|
||||
- ask_user
|
||||
- finish
|
||||
|
||||
## 恢复执行
|
||||
|
||||
当 `ExecutionState.Status == waiting_user` 时,下一条用户消息会被视为对上一轮追问的回复。
|
||||
|
||||
当前保护机制:
|
||||
|
||||
- 从已有 plan 中提取最近一次追问内容
|
||||
- 将用户回复作为 `user_reply` observation 追加
|
||||
- 在 planner prompt 中注入显式的 `Resume context`
|
||||
|
||||
这样可以减少用户只回复 `是` 这类短消息时,被错误理解成全新意图的情况。
|
||||
|
||||
## 动态状态刷新
|
||||
|
||||
配置类与 trader 管理类请求本质上是动态请求,它们的真相可能在聊天之外发生变化,例如:
|
||||
|
||||
- 用户在 Web UI 中配置了交易所
|
||||
- 用户在另一个页面新增了模型
|
||||
- 用户在别处创建了 trader
|
||||
|
||||
因此,这类请求不能依赖旧的模型结论。
|
||||
|
||||
当前在 `planner_runtime.go` 中的保护措施:
|
||||
|
||||
- 通过 `isConfigOrTraderIntent(...)` 检测配置 / trader 意图
|
||||
- 这类请求在 planner prompt 中不再注入旧 `TaskState`
|
||||
- 同时刷新 `ExecutionState.Observations` 中的实时快照:
|
||||
- `toolGetModelConfigs(...)`
|
||||
- `toolGetExchangeConfigs(...)`
|
||||
- `toolListTraders(...)`
|
||||
|
||||
这样 planner 会更多依赖当前系统状态,而不是依赖旧记忆中的描述。
|
||||
|
||||
## 重置策略
|
||||
|
||||
当前系统在以下场景会重置或弱化旧执行态:
|
||||
|
||||
- 用户说了类似 `再试`、`继续`、`try again`、`continue`
|
||||
- 当前请求是配置 / trader 相关,并且旧 `ExecutionState` 已经失败 / 完成 / 正在等待用户
|
||||
|
||||
重置范围:
|
||||
|
||||
- `ExecutionState` 可能会被清空
|
||||
- `TaskState` 不会整体删除,但在配置 / trader 请求中会被主动忽略
|
||||
|
||||
手动清理:
|
||||
|
||||
- `/clear`
|
||||
|
||||
这条命令会清掉:
|
||||
|
||||
- 短期 chat history
|
||||
- task state
|
||||
- execution state
|
||||
|
||||
## 压缩设计
|
||||
|
||||
`maybeCompressHistory(...)` 会在以下条件满足时把旧的短期对话压缩进 `TaskState`:
|
||||
|
||||
- 最近消息数超过窗口
|
||||
- 估算 token 数超过阈值
|
||||
|
||||
压缩流程:
|
||||
|
||||
1. 保留最近若干轮对话在 `chatHistory`
|
||||
2. 把更早的内容总结成结构化 `TaskState`
|
||||
3. 持久化新的 `TaskState`
|
||||
4. 用最近消息切片替换 `chatHistory`
|
||||
|
||||
重要设计原则:
|
||||
|
||||
- `TaskState` 只保留长期有效上下文
|
||||
- 不能把它变成动态运营状态的陈旧副本
|
||||
|
||||
## 当前架构图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
U[用户消息] --> A[HandleMessage / HandleMessageStream]
|
||||
A --> B{是否命中直达分支?}
|
||||
B -->|是| C[直接处理 slash command 或快捷分支]
|
||||
B -->|否| D[thinkAndAct / thinkAndActStream]
|
||||
|
||||
D --> E[写入 chatHistory]
|
||||
D --> F[加载 ExecutionState]
|
||||
F --> G{是否 waiting_user?}
|
||||
G -->|是| H[追加 user_reply observation]
|
||||
G -->|否| I[创建新的 ExecutionState]
|
||||
|
||||
H --> J[若为配置或 trader 请求则刷新动态快照]
|
||||
I --> J
|
||||
J --> K[createExecutionPlan 调用 LLM]
|
||||
K --> L[得到 execution plan]
|
||||
L --> M[executePlan 循环执行]
|
||||
|
||||
M --> N[tool step]
|
||||
M --> O[reason step]
|
||||
M --> P[ask_user step]
|
||||
M --> Q[respond step]
|
||||
|
||||
N --> R[写入 Observation]
|
||||
O --> R
|
||||
R --> S[replanAfterStep]
|
||||
S --> M
|
||||
|
||||
P --> T[持久化 waiting_user ExecutionState]
|
||||
T --> UQ[向用户返回追问]
|
||||
|
||||
Q --> V[持久化 completed ExecutionState]
|
||||
V --> W[把 assistant 回复写入 chatHistory]
|
||||
W --> X[maybeCompressHistory]
|
||||
X --> Y[持久化 TaskState]
|
||||
Y --> Z[返回最终回答]
|
||||
```
|
||||
|
||||
## 记忆关系图
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CH[chatHistory\n内存态\n最近对话]
|
||||
TS[TaskState\n持久化摘要\nsystem_config]
|
||||
ES[ExecutionState\n持久化执行态\nsystem_config]
|
||||
PL[Planner Prompt]
|
||||
|
||||
CH -->|最近原始对话| PL
|
||||
ES -->|当前工作流 JSON| PL
|
||||
TS -->|长期结构化上下文| PL
|
||||
|
||||
CH -->|旧消息压缩| TS
|
||||
PL -->|计划 / 观察 / 状态| ES
|
||||
```
|
||||
|
||||
## 状态转换图
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> planning
|
||||
planning --> running: plan created
|
||||
running --> waiting_user: ask_user step
|
||||
waiting_user --> planning: user replies
|
||||
running --> completed: respond step finished
|
||||
running --> failed: step error
|
||||
failed --> planning: retry / continue / config-trader reset
|
||||
completed --> planning: new relevant request or retry flow
|
||||
```
|
||||
|
||||
## 当前设计的取舍
|
||||
|
||||
### 优点
|
||||
|
||||
- 将短期对话与长期摘要分离
|
||||
- 支持在 `ask_user` 之后恢复执行
|
||||
- 每个关键步骤后都支持重规划
|
||||
- 对配置 / 创建 trader 这类动态请求,已经能更好抵抗旧结论污染
|
||||
|
||||
### 缺点
|
||||
|
||||
- `TaskState` 的质量仍然依赖总结效果
|
||||
- 某些恢复逻辑仍依赖模型是否听话
|
||||
- 每个用户当前只有一条 `ExecutionState`,不支持多个并发工作流
|
||||
- 配置 / trader 意图识别目前仍是关键词启发式
|
||||
|
||||
## 实践建议
|
||||
|
||||
### 什么时候该相信 `TaskState`
|
||||
|
||||
应该相信它用于:
|
||||
|
||||
- 延续用户目标
|
||||
- 跟踪未完成事项
|
||||
- 保留长期有效事实
|
||||
|
||||
不应该相信它用于:
|
||||
|
||||
- 当前是否存在模型 / 交易所 / trader 配置
|
||||
- 当前是否能够执行某个操作
|
||||
|
||||
### 什么时候该相信 `ExecutionState`
|
||||
|
||||
应该相信它用于:
|
||||
|
||||
- 当前工作流是否仍然连续
|
||||
- 当前阻塞在哪一步
|
||||
- 最近的 observation 链条
|
||||
|
||||
不应该盲信它用于:
|
||||
|
||||
- 用户在聊天外已经修改过配置的场景
|
||||
- 系统能力或工具集发生变化后的旧结论
|
||||
|
||||
### 什么时候必须重新获取实时状态
|
||||
|
||||
以下场景应该优先重新通过工具获取:
|
||||
|
||||
- 当前模型配置
|
||||
- 当前交易所配置
|
||||
- 当前 trader 列表
|
||||
- 当前是否满足 trader 创建条件
|
||||
|
||||
## 后续建议
|
||||
|
||||
- 为 `ExecutionState` 增加版本号或能力签名,能力变化时自动失效
|
||||
- 将 `waiting_user_confirmation` 与通用 `waiting_user` 分开
|
||||
- 对 `是`、`好`、`继续` 这类短确认增加代码级识别
|
||||
- 将动态快照刷新从启发式升级为显式 planner 预检查阶段
|
||||
- 如果后续需要,支持一个用户多条并发执行会话
|
||||
44
docs/assets/nofx-banner.svg
Normal file
44
docs/assets/nofx-banner.svg
Normal file
@@ -0,0 +1,44 @@
|
||||
<svg width="1200" height="380" viewBox="0 0 1200 380" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<clipPath id="frame"><rect width="1200" height="380" rx="14"/></clipPath>
|
||||
<pattern id="dots" width="28" height="28" patternUnits="userSpaceOnUse">
|
||||
<circle cx="1.5" cy="1.5" r="1.2" fill="#1A1813" opacity="0.07"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<g clip-path="url(#frame)">
|
||||
<rect width="1200" height="380" fill="#F1ECE2"/>
|
||||
<rect width="1200" height="380" fill="url(#dots)"/>
|
||||
|
||||
<!-- title bar -->
|
||||
<rect width="1200" height="52" fill="#F7F4EC"/>
|
||||
<line x1="0" y1="52.5" x2="1200" y2="52.5" stroke="#1A1813" stroke-opacity="0.14"/>
|
||||
<circle cx="30" cy="26" r="6" fill="#D6433A" opacity="0.9"/>
|
||||
<circle cx="54" cy="26" r="6" fill="#B4B2A9"/>
|
||||
<circle cx="78" cy="26" r="6" fill="#2E8B57" opacity="0.9"/>
|
||||
<text x="600" y="31" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="13" letter-spacing="2" fill="#8A8478">nofx — ai trading terminal</text>
|
||||
|
||||
<!-- wordmark -->
|
||||
<text x="64" y="182" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="92" font-weight="700" letter-spacing="4" fill="#1A1813">NOFX<tspan fill="#E0483B">_</tspan></text>
|
||||
<text x="64" y="228" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="17" letter-spacing="9" fill="#6B6557">AI TRADING TERMINAL</text>
|
||||
|
||||
<text x="64" y="286" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="15" fill="#6B6557">Language models make the decisions.</text>
|
||||
<text x="64" y="312" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="15" fill="#6B6557">A Go runtime enforces the limits.</text>
|
||||
|
||||
<!-- divider -->
|
||||
<line x1="672" y1="84" x2="672" y2="344" stroke="#1A1813" stroke-opacity="0.12"/>
|
||||
|
||||
<!-- session log -->
|
||||
<g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="14.5">
|
||||
<text x="712" y="112" fill="#8A8478">$ nofx start</text>
|
||||
<text x="712" y="146"><tspan fill="#2E8B57">✓</tspan><tspan fill="#1A1813" dx="10">preflight passed</tspan><tspan fill="#8A8478" dx="10">7/7 checks</tspan></text>
|
||||
<text x="712" y="180"><tspan fill="#2E8B57">✓</tspan><tspan fill="#1A1813" dx="10">trader online</tspan><tspan fill="#8A8478" dx="10">deepseek · hyperliquid</tspan></text>
|
||||
<text x="712" y="222"><tspan fill="#8A8478">decision</tspan><tspan fill="#E0483B" dx="12" font-weight="700">OPEN_LONG</tspan><tspan fill="#1A1813" dx="12">BTC ×3</tspan></text>
|
||||
<text x="712" y="252"><tspan fill="#8A8478">reason</tspan><tspan fill="#6B6557" dx="12">oi rising · funding neutral · 4h breakout</tspan></text>
|
||||
<text x="712" y="282"><tspan fill="#8A8478">risk</tspan><tspan fill="#6B6557" dx="12">sl −2.5% · tp +5.0% · placed on exchange</tspan></text>
|
||||
<text x="712" y="324"><tspan fill="#8A8478">equity</tspan><tspan fill="#1A1813" dx="12">$12,847</tspan><tspan fill="#8A8478" dx="18">pnl</tspan><tspan fill="#2E8B57" dx="12">+4.2%</tspan></text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<rect x="0.5" y="0.5" width="1199" height="379" rx="14" fill="none" stroke="#1A1813" stroke-opacity="0.22"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -12,8 +12,11 @@ NOFX 文档提供多种语言版本。
|
||||
|----------|-------------|--------|-------------|
|
||||
| 🇬🇧 **English** | [README.md](../../README.md) | ✅ Complete | Core Team |
|
||||
| 🇨🇳 **Chinese (中文)** | [README.md](zh-CN/README.md) | ✅ Complete | Community |
|
||||
| 🇯🇵 **Japanese (日本語)** | [README.md](ja/README.md) | ✅ Complete | Community |
|
||||
| 🇰🇷 **Korean (한국어)** | [README.md](ko/README.md) | ✅ Complete | Community |
|
||||
| 🇷🇺 **Russian (Русский)** | [README.md](ru/README.md) | ✅ Complete | Community |
|
||||
| 🇺🇦 **Ukrainian (Українська)** | [README.md](uk/README.md) | ✅ Complete | Community |
|
||||
| 🇻🇳 **Vietnamese (Tiếng Việt)** | [README.md](vi/README.md) | ✅ Complete | Community |
|
||||
|
||||
---
|
||||
|
||||
@@ -30,6 +33,16 @@ NOFX 文档提供多种语言版本。
|
||||
- **安全政策:** [../../SECURITY.md](../../SECURITY.md#中文)
|
||||
- **常见问题:** [../guides/faq.zh-CN.md](../guides/faq.zh-CN.md)
|
||||
|
||||
### 日本語 🇯🇵
|
||||
- **メイン README:** [ja/README.md](ja/README.md)
|
||||
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
- **Security:** [../../SECURITY.md](../../SECURITY.md)
|
||||
|
||||
### 한국어 🇰🇷
|
||||
- **메인 README:** [ko/README.md](ko/README.md)
|
||||
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
- **Security:** [../../SECURITY.md](../../SECURITY.md)
|
||||
|
||||
### Русский 🇷🇺
|
||||
- **Основной README:** [ru/README.md](ru/README.md)
|
||||
- **Руководство по участию:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
@@ -40,6 +53,11 @@ NOFX 文档提供多种语言版本。
|
||||
- **Посібник із внесків:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
- **Політика безпеки:** [../../SECURITY.md](../../SECURITY.md)
|
||||
|
||||
### Tiếng Việt 🇻🇳
|
||||
- **README chính:** [vi/README.md](vi/README.md)
|
||||
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
- **Security:** [../../SECURITY.md](../../SECURITY.md)
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Help with Translations / 帮助翻译
|
||||
@@ -49,7 +67,7 @@ NOFX 文档提供多种语言版本。
|
||||
We welcome translation contributions! / 我们欢迎翻译贡献!
|
||||
|
||||
**What needs translation? / 需要翻译什么?**
|
||||
- ✅ Main README (complete for 4 languages)
|
||||
- ✅ Main README (complete for 7 languages)
|
||||
- 🚧 Deployment guides (partial)
|
||||
- 📋 User guides (needed)
|
||||
- 📋 Contributing guide (needed for RU/UK)
|
||||
@@ -94,10 +112,11 @@ faq.zh-CN.md → Chinese FAQ
|
||||
**Language Codes:**
|
||||
- `en` - English (default, no suffix needed)
|
||||
- `zh-CN` - Simplified Chinese
|
||||
- `ja` - Japanese
|
||||
- `ko` - Korean
|
||||
- `ru` - Russian
|
||||
- `uk` - Ukrainian
|
||||
- `ja` - Japanese *(future)*
|
||||
- `ko` - Korean *(future)*
|
||||
- `vi` - Vietnamese
|
||||
|
||||
### Quality Standards / 质量标准
|
||||
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<h1 align="center">NOFX</h1>
|
||||
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a> の支援を受けています</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<strong>あなた専属の AI トレーディングアシスタント。</strong><br/>
|
||||
<strong>あらゆる市場。あらゆるモデル。API キー不要、USDC で支払い。</strong>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI トレーディングターミナル" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,145 +21,262 @@
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
NOFX はオープンソースの**自律型** AI トレーディングアシスタントです。従来の AI ツールのように手動でモデルを設定し、API キーを管理し、データソースを接続する必要はありません — NOFX の AI は**市場を自ら認識し、モデルを自ら選択し、データを自ら取得します**。人間の介入はゼロ。あなたは戦略を設定するだけ、残りは AI が処理します。
|
||||
NOFX は、戦略そのものが言語モデルであるオープンソースのトレーディングターミナルです。各トレーダーは、市場構造を読み取り、判断し、執行し、その根拠を記録するという連続的なループを回し続けます。その間、Go ランタイムがすべての注文を、モデルには上書きできないハードなリスク制限内に抑え込みます。
|
||||
|
||||
**完全自律**: AI がどのモデルを使うか、どの市場データを取得するか、いつ取引するかを自ら判断します。手動のモデル設定不要。複数サービスの API キー管理不要。USDC ウォレットに入金して実行するだけ。
|
||||
|
||||
他との違い:**[x402](https://x402.org) マイクロペイメント内蔵**。API キー不要。USDC ウォレットに入金してリクエストごとに支払い。ウォレットがあなたの身分証明。
|
||||
トレーダーの構成は自由です。任意のモデル、9 つの取引所のいずれか、任意の戦略を組み合わせられます。複数のトレーダーを並行して走らせ、実現リターンに基づく公開リーダーボードで比較できます。すべては自分のマシン上で動作し、取引所の認証情報は保存時に暗号化され、外部に送信されることはありません。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
**http://127.0.0.1:3000** を開く。完了。
|
||||
ターミナルは `http://127.0.0.1:3000` で開きます。
|
||||
|
||||
---
|
||||
**初回起動**
|
||||
|
||||
## x402 の仕組み
|
||||
1. 登録します — 最初に作成したアカウントがこのインスタンスのオーナーになります。
|
||||
2. ガイド付きの起動手順に従います。自動作成される AI 手数料ウォレットに **$1+ USDC**(Base ネットワーク)を入金し、続いて Hyperliquid を接続して取引資金として **$12+ USDC** を入金します。
|
||||
3. **Autopilot** を開始します。AI は数分ごとに市場をスキャンして自律的に取引し、すべての判断はその場でダッシュボードに表示されます。ワンクリックでいつでも停止できます。
|
||||
|
||||
従来のフロー:アカウント登録 → クレジット購入 → API キー取得 → クォータ管理 → キーのローテーション。
|
||||
<br/>
|
||||
|
||||
x402 フロー:
|
||||
## 取引所の登録
|
||||
|
||||
```
|
||||
リクエスト → 402(価格提示)→ ウォレットが USDC を署名 → リトライ → 完了
|
||||
```
|
||||
NOFX は無料のオープンソースソフトウェアです。以下のパートナーリンク経由で口座を開設すると、取引手数料の割引が受けられるうえ、継続的な開発の資金にもなります。
|
||||
|
||||
アカウント不要。API キー不要。前払いクレジット不要。ウォレット1つで全モデル。
|
||||
|
||||
### 内蔵 x402 プロバイダー
|
||||
|
||||
| プロバイダー | チェーン | モデル |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ モデル |
|
||||
|
||||
---
|
||||
|
||||
## 機能
|
||||
|
||||
| 機能 | 説明 |
|
||||
|:--------|:------------|
|
||||
| **マルチ AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — いつでも切替 |
|
||||
| **マルチ取引所** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **ストラテジースタジオ** | ビジュアルビルダー — コインソース、インジケーター、リスク管理 |
|
||||
| **AI ディベートアリーナ** | 複数 AI が取引を議論(ブル vs ベア vs アナリスト)、投票、実行 |
|
||||
| **AI 競争** | AI がリアルタイムで競争、リーダーボードで成績ランキング |
|
||||
| **Telegram エージェント** | トレーディングアシスタントとチャット — ストリーミング、ツール呼び出し、メモリ |
|
||||
| **バックテストラボ** | 過去データシミュレーション、エクイティカーブと成績指標 |
|
||||
| **ダッシュボード** | ライブポジション、損益、Chain of Thought 付き AI 判断ログ |
|
||||
|
||||
### 市場
|
||||
|
||||
暗号通貨 · 米国株 · FX · 貴金属
|
||||
|
||||
### 取引所 (CEX)
|
||||
|
||||
| 取引所 | ステータス | 登録 (手数料割引) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| 取引所 | 状態 | 手数料割引付きで登録 |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [登録](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [登録](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [登録](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### 取引所 (Perp-DEX)
|
||||
|
||||
| 取引所 | ステータス | 登録 (手数料割引) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [登録](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI モデル (API キーモード)
|
||||
<br/>
|
||||
|
||||
| AI モデル | ステータス | API キー取得 |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [API キー取得](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [API キー取得](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [API キー取得](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [API キー取得](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [API キー取得](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [API キー取得](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [API キー取得](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [API キー取得](https://platform.minimaxi.com) |
|
||||
## デモ
|
||||
|
||||
### AI モデル (x402 モード — API キー不要)
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
15+ モデルを [Claw402](https://claw402.ai) 経由で利用 — USDC ウォレットのみ
|
||||
<br/>
|
||||
|
||||
---
|
||||
## モデルは提案する。ランタイムが決める。
|
||||
|
||||
判断は、[Claw402.ai](https://claw402.ai)・Vergex のデータスタックを読み取る言語モデルから生まれます。全市場を方向バイアスとシグナル強度でランク付けするライブシグナルボード、銘柄ごとの Signal Lab ディープシグナル、群衆の「燃料」と「壁」の位置を映すコストベース・清算ヒートマップ、リアルタイムのネットフロー — これらを生のローソク足とトレーダー自身のライブ実績と突き合わせます。ただし、執行はそうではありません。
|
||||
|
||||
すべての注文は、モデルの手が届かない、コードで強制されるリスク制限を通過します。
|
||||
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| ポジション制限 | 同時ポジション数の上限、想定元本は口座資産に対する比率で制限、1 シンボルにつき 1 ポジション |
|
||||
| レバレッジ制限 | モデルの要求とは無関係に、注文サイズの算出時にハードキャップを適用 |
|
||||
| 取引所側の保護 | すべてのエントリー直後に、ストップロスとテイクプロフィットを取引所側に設置 |
|
||||
| ドローダウン自動クローズ | ピークから利益を大きく吐き出したポジションは自動的にクローズ |
|
||||
| 取引スロットリング | 最低保有時間、シンボルごとの再エントリークールダウン、サイクルごと・1 時間ごとのエントリー回数制限 |
|
||||
| セーフモード | モデルの失敗が繰り返された場合、モデルが回復するまで新規エントリーをブロック |
|
||||
| 起動前チェック | トレーダーの開始前に、モデルアクセス、ウォレット資金、戦略、取引所残高を検証 |
|
||||
|
||||
各判断は、モデルの推論全文とともに保存されます。記録の残らないポジションは存在しません。
|
||||
|
||||
<br/>
|
||||
|
||||
## ターミナル
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Autopilot** | ガイド付きの起動フロー:資金投入、接続、入金、開始 — 全工程をサーバー側の事前チェックが支えます |
|
||||
| **Strategy Studio** | スタイルプリセット、コインユニバース、インジケーター、レバレッジ、エントリー確信度、カスタムプロンプト |
|
||||
| **Competition** | 実現リターンでランク付けされる公開リーダーボード。各エントリーには使用モデルが明記されます |
|
||||
| **Dashboard** | ライブのポジション、注文、統計、そしてすべての判断の背後にある推論 |
|
||||
|
||||
<details>
|
||||
<summary>スクリーンショット</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| 概要 | マーケットチャート |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| 取引統計 | ポジション履歴 |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| 戦略エディタ | インジケーター設定 |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
| コンペティション | 設定 |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## モデル
|
||||
|
||||
自分の API キーで 8 つのプロバイダーを利用できます — DeepSeek、OpenAI、Claude、Qwen、Gemini、Grok、Kimi、MiniMax。カスタムエンドポイントとカスタムモデル名にも対応しています。
|
||||
|
||||
あるいは、キーを一切使わない方法もあります。[Claw402](https://claw402.ai) は x402 プロトコル上で、モデルの利用量を呼び出しごとに USDC で課金します。Base 上のウォレットひとつが、すべての API キーの代わりになります。
|
||||
|
||||
| プロバイダー | アクセス |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [公式割引付きの従量課金 AI モデル](https://claw402.ai) |
|
||||
|
||||
## マーケット
|
||||
|
||||
9 つの取引所すべてで暗号資産の無期限先物を取引できます。Hyperliquid では、同じランタイムが暗号資産に加えて、トークン化された米国株、コモディティ、株価指数、FX、プレ IPO 無期限先物 — TSLA、NVDA、GOLD、SPX、EUR、OPENAI — も取引します。
|
||||
|
||||
<br/>
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
<br/>
|
||||
|
||||
## インストール
|
||||
|
||||
### Linux / macOS
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (クラウド)
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### ソースから
|
||||
**Windows** — [Docker Desktop](https://www.docker.com/products/docker-desktop/) をインストールしてから、次を実行します。
|
||||
|
||||
```bash
|
||||
# 前提条件: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # バックエンド
|
||||
cd web && npm install && npm run dev # フロントエンド(新しいターミナル)
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
**ソースからビルド** — Go 1.21+、Node.js 18+ が必要です。
|
||||
|
||||
## リンク
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
**アップデート** — インストールスクリプトを再実行すると、その場でアップグレードされます。
|
||||
|
||||
<details>
|
||||
<summary>サーバーへのデプロイ</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**Cloudflare 経由の HTTPS**
|
||||
|
||||
1. [Cloudflare](https://dash.cloudflare.com)(無料プラン)にドメインを追加
|
||||
2. A レコード → サーバー IP(Proxied を有効化)
|
||||
3. SSL/TLS → Flexible
|
||||
4. `.env` に `TRANSPORT_ENCRYPTION=true` を設定
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## ドキュメント
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| ウェブサイト | [nofxai.com](https://nofxai.com) |
|
||||
| ダッシュボード | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API ドキュメント | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [はじめに](../../getting-started/README.md) | デプロイと取引所 API のガイド |
|
||||
| [アーキテクチャ](../../architecture/README.md) | システム設計とモジュール索引 |
|
||||
| [戦略モジュール](../../architecture/STRATEGY_MODULE.md) | 銘柄選択、AI プロンプト、執行 |
|
||||
| [FAQ](../../guides/faq.en.md) | よくある質問 |
|
||||
| [トラブルシューティング](../../guides/TROUBLESHOOTING.md) | よくある問題の診断 |
|
||||
|
||||
> **リスク警告**: AI 自動取引には重大なリスクがあります。学習/研究目的または少額でのテストのみを推奨します。
|
||||
## コミュニティ
|
||||
|
||||
---
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [ライブダッシュボード](https://vergex.trade/explore)
|
||||
|
||||
## License
|
||||
## コントリビューション
|
||||
|
||||
コード、ドキュメント、翻訳、バグ報告のいずれも歓迎します。詳細は[貢献ガイド](../../../CONTRIBUTING.md)、[行動規範](../../../CODE_OF_CONDUCT.md)、[セキュリティポリシー](../../../SECURITY.md)をご覧ください。
|
||||
|
||||
NOFX は有意義な貢献を記録しており、エコシステムの成長に応じて貢献者に還元していく予定です。優先度の高い Issue にはより高いウェイトが付きます。
|
||||
|
||||
| 貢献の種類 | ウェイト |
|
||||
| :---------------- | :----: |
|
||||
| ピン留め Issue の PR | ★★★★★★ |
|
||||
| コード(マージ済み PR) | ★★★★★ |
|
||||
| バグ修正 | ★★★★ |
|
||||
| 機能アイデア | ★★★ |
|
||||
| バグ報告 | ★★ |
|
||||
| ドキュメント | ★★ |
|
||||
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## スポンサー
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
[スポンサーになる](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
<br/>
|
||||
|
||||
NOFX が役に立ったら、スターを付けていただけると、他のトレーダーがこのプロジェクトを見つけやすくなります。
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## ライセンス
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>自動売買には大きなリスクが伴います。AI 駆動の戦略は実験的なものであり、損失を出す可能性があります。適切なポジションサイズを守り、各取引所の仕組みを理解し、失っても差し支えのない資金以外では決して取引しないでください。詳細は[免責事項](../../../DISCLAIMER.md)をご覧ください。</sub>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<h1 align="center">NOFX</h1>
|
||||
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a>가 지원합니다</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<strong>당신만의 AI 트레이딩 어시스턴트.</strong><br/>
|
||||
<strong>모든 시장. 모든 모델. API 키 없이 USDC로 결제.</strong>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,145 +21,262 @@
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
NOFX는 오픈소스 **자율형** AI 트레이딩 어시스턴트입니다. 수동으로 모델을 설정하고, API 키를 관리하고, 데이터 소스를 연결해야 하는 기존 AI 도구와 달리 — NOFX의 AI는 **시장을 스스로 인식하고, 모델을 스스로 선택하고, 데이터를 스스로 가져옵니다**. 인간 개입 제로. 전략만 설정하면 나머지는 AI가 처리합니다.
|
||||
NOFX는 언어 모델이 곧 전략이 되는 오픈소스 트레이딩 터미널입니다. 각 트레이더는 시장 구조 읽기, 판단, 실행, 근거 기록으로 이어지는 루프를 끊임없이 반복하고, Go 런타임은 모든 주문을 모델이 절대 우회할 수 없는 하드 리스크 한도 안에 묶어 둡니다.
|
||||
|
||||
**완전 자율**: AI가 어떤 모델을 사용할지, 어떤 시장 데이터를 가져올지, 언제 거래할지를 스스로 결정합니다. 수동 모델 설정 불필요. 여러 서비스의 API 키 관리 불필요. USDC 지갑에 충전하고 실행하기만 하면 됩니다.
|
||||
|
||||
차별점: **[x402](https://x402.org) 마이크로 결제 내장**. API 키 불필요. USDC 지갑에 충전하고 요청마다 결제. 지갑이 곧 신원.
|
||||
트레이더는 자유롭게 조합할 수 있습니다. 어떤 모델이든, 아홉 개 거래소 중 어디든, 어떤 전략이든 가능합니다. 여러 트레이더를 나란히 돌리면서 실현 수익률 기준의 공개 리더보드에서 비교해 보세요. 모든 것은 사용자의 머신에서 실행되며, 거래소 자격 증명은 저장 시 암호화되고 절대 외부로 나가지 않습니다.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
**http://127.0.0.1:3000** 을 열면 완료.
|
||||
터미널은 `http://127.0.0.1:3000`에서 열립니다.
|
||||
|
||||
---
|
||||
**첫 실행**
|
||||
|
||||
## x402 작동 방식
|
||||
1. 계정을 등록합니다 — 첫 번째 계정이 해당 인스턴스의 소유자가 됩니다.
|
||||
2. 가이드 런치를 따라갑니다. 자동으로 생성된 AI 수수료 지갑에 **$1+ USDC**(Base 네트워크)를 넣은 뒤, Hyperliquid를 연결하고 거래 자금으로 **$12+ USDC**를 입금합니다.
|
||||
3. **Autopilot**을 시작합니다. AI가 몇 분마다 시장을 스캔하며 스스로 거래하고, 모든 결정은 발생하는 즉시 대시보드에 표시됩니다. 언제든 클릭 한 번으로 중지할 수 있습니다.
|
||||
|
||||
기존 플로우: 계정 등록 → 크레딧 구매 → API 키 받기 → 쿼터 관리 → 키 교체.
|
||||
<br/>
|
||||
|
||||
x402 플로우:
|
||||
## 거래소 등록
|
||||
|
||||
```
|
||||
요청 → 402 (가격 제시) → 지갑이 USDC 서명 → 재시도 → 완료
|
||||
```
|
||||
NOFX는 무료 오픈소스입니다. 아래 파트너 링크로 계정을 개설하면 거래 수수료가 할인되며, 프로젝트의 지속적인 개발에도 보탬이 됩니다.
|
||||
|
||||
계정 불필요. API 키 불필요. 선불 크레딧 불필요. 지갑 하나로 모든 모델.
|
||||
|
||||
### 내장 x402 프로바이더
|
||||
|
||||
| 프로바이더 | 체인 | 모델 |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ 모델 |
|
||||
|
||||
---
|
||||
|
||||
## 기능
|
||||
|
||||
| 기능 | 설명 |
|
||||
|:--------|:------------|
|
||||
| **멀티 AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — 언제든 전환 |
|
||||
| **멀티 거래소** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **전략 스튜디오** | 비주얼 빌더 — 코인 소스, 지표, 리스크 관리 |
|
||||
| **AI 토론 아레나** | 여러 AI가 거래 토론 (강세 vs 약세 vs 분석가), 투표, 실행 |
|
||||
| **AI 경쟁** | AI가 실시간 경쟁, 리더보드 순위 |
|
||||
| **Telegram 에이전트** | 트레이딩 어시스턴트와 채팅 — 스트리밍, 도구 호출, 메모리 |
|
||||
| **백테스트 랩** | 과거 시뮬레이션, 자산 곡선 및 성과 지표 |
|
||||
| **대시보드** | 실시간 포지션, 손익, Chain of Thought AI 결정 로그 |
|
||||
|
||||
### 시장
|
||||
|
||||
암호화폐 · 미국 주식 · 외환 · 귀금속
|
||||
|
||||
### 거래소 (CEX)
|
||||
|
||||
| 거래소 | 상태 | 등록 (수수료 할인) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| 거래소 | 상태 | 수수료 할인 등록 |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [등록](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [등록](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [등록](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [등록](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [등록](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### 거래소 (Perp-DEX)
|
||||
|
||||
| 거래소 | 상태 | 등록 (수수료 할인) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [등록](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI 모델 (API 키 모드)
|
||||
<br/>
|
||||
|
||||
| AI 모델 | 상태 | API 키 받기 |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [API 키 받기](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [API 키 받기](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [API 키 받기](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [API 키 받기](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [API 키 받기](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [API 키 받기](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [API 키 받기](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [API 키 받기](https://platform.minimaxi.com) |
|
||||
## 데모
|
||||
|
||||
### AI 모델 (x402 모드 — API 키 불필요)
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
15+ 모델을 [Claw402](https://claw402.ai)로 이용 — USDC 지갑만 있으면 됩니다
|
||||
<br/>
|
||||
|
||||
---
|
||||
## 모델은 제안하고, 런타임이 결정합니다
|
||||
|
||||
의사결정은 [Claw402.ai](https://claw402.ai) · Vergex 데이터 스택을 읽는 언어 모델에서 나옵니다. 모든 시장을 방향 편향과 시그널 강도로 순위 매기는 실시간 시그널 보드, 종목별 Signal Lab 딥 시그널, 시장의 '연료'와 '벽'이 어디에 있는지 보여주는 코스트·청산 히트맵, 실시간 순유입 — 이를 원시 캔들과 트레이더 자신의 실시간 트랙 레코드로 교차 검증합니다. 그러나 실행은 그렇지 않습니다.
|
||||
|
||||
모든 주문은 모델의 손이 닿지 않는 곳에서 코드로 강제되는 한도를 통과해야 합니다.
|
||||
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| 포지션 한도 | 최대 동시 포지션 수, 자본 대비 비율로 상한이 걸린 명목 금액, 심볼당 포지션 1개 |
|
||||
| 레버리지 클램프 | 모델이 무엇을 요청하든 관계없이 주문 크기 산정 시점에 적용되는 하드 캡 |
|
||||
| 거래소 측 보호 | 모든 진입 직후 거래소에 스탑로스와 테이크프로핏을 즉시 배치 |
|
||||
| 드로다운 자동 청산 | 고점 대비 이익을 과도하게 반납한 수익 포지션은 자동으로 청산 |
|
||||
| 거래 스로틀링 | 최소 보유 시간, 심볼별 재진입 쿨다운, 사이클당·시간당 진입 횟수 제한 |
|
||||
| 세이프 모드 | 모델 오류가 반복되면 모델이 복구될 때까지 신규 진입을 차단 |
|
||||
| 런치 프리플라이트 | 트레이더 시작 전에 모델 접근성, 지갑 자금, 전략, 거래소 잔고를 검증 |
|
||||
|
||||
각 결정은 모델의 전체 추론 과정과 함께 저장됩니다. 기록 없는 포지션은 존재하지 않습니다.
|
||||
|
||||
<br/>
|
||||
|
||||
## 터미널
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Autopilot** | 가이드 런치: 자금 입금, 연결, 예치, 시작 — 전 과정에 서버 측 프리플라이트 적용 |
|
||||
| **Strategy Studio** | 스타일 프리셋, 코인 유니버스, 지표, 레버리지, 진입 신뢰도, 커스텀 프롬프트 |
|
||||
| **경쟁** | 실현 수익률로 순위가 매겨지는 공개 리더보드, 각 항목마다 사용한 모델 표기 |
|
||||
| **대시보드** | 실시간 포지션, 주문, 통계, 그리고 모든 결정 뒤에 있는 추론 과정 |
|
||||
|
||||
<details>
|
||||
<summary>스크린샷</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| 개요 | 마켓 차트 |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| 거래 통계 | 포지션 기록 |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| 전략 에디터 | 지표 설정 |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
| 경쟁 | 설정 |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## 모델
|
||||
|
||||
자신의 키로 사용할 수 있는 여덟 개 제공업체 — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — 커스텀 엔드포인트와 모델 이름도 지원합니다.
|
||||
|
||||
키가 전혀 없어도 됩니다. [Claw402](https://claw402.ai)는 x402 프로토콜을 통해 모델 사용량을 호출 단위로 USDC로 정산합니다. Base 네트워크의 지갑 하나가 모든 API 키를 대신합니다.
|
||||
|
||||
| 제공업체 | 이용 방법 |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [공식 할인이 적용된 종량제 AI 모델](https://claw402.ai) |
|
||||
|
||||
## 시장
|
||||
|
||||
아홉 개 거래소 전체에서 암호화폐 무기한 선물을 거래합니다. Hyperliquid에서는 같은 런타임이 암호화폐와 함께 토큰화된 미국 주식, 원자재, 지수, 외환, 프리 IPO 무기한 선물 — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — 도 거래합니다.
|
||||
|
||||
<br/>
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
<br/>
|
||||
|
||||
## 설치
|
||||
|
||||
### Linux / macOS
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (클라우드)
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 소스에서
|
||||
**Windows** — [Docker Desktop](https://www.docker.com/products/docker-desktop/)을 설치한 뒤:
|
||||
|
||||
```bash
|
||||
# 필수 조건: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # 백엔드
|
||||
cd web && npm install && npm run dev # 프론트엔드 (새 터미널)
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
**소스에서 빌드** — Go 1.21+, Node.js 18+:
|
||||
|
||||
## 링크
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
**업데이트** — 설치 스크립트를 다시 실행하면 제자리에서 업그레이드됩니다.
|
||||
|
||||
<details>
|
||||
<summary>서버 배포</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**Cloudflare를 통한 HTTPS**
|
||||
|
||||
1. [Cloudflare](https://dash.cloudflare.com)에 도메인 추가(무료 플랜)
|
||||
2. A 레코드 → 서버 IP, Proxied 설정
|
||||
3. SSL/TLS → Flexible
|
||||
4. `.env`에 `TRANSPORT_ENCRYPTION=true` 설정
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## 문서
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| 웹사이트 | [nofxai.com](https://nofxai.com) |
|
||||
| 대시보드 | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API 문서 | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [시작하기](../../getting-started/README.md) | 배포 및 거래소 API 가이드 |
|
||||
| [아키텍처](../../architecture/README.md) | 시스템 설계와 모듈 색인 |
|
||||
| [전략 모듈](../../architecture/STRATEGY_MODULE.md) | 코인 선정, AI 프롬프트, 실행 |
|
||||
| [FAQ](../../guides/faq.en.md) | 자주 묻는 질문 |
|
||||
| [문제 해결](../../guides/TROUBLESHOOTING.md) | 흔한 문제 진단하기 |
|
||||
|
||||
> **위험 경고**: AI 자동 거래에는 상당한 위험이 있습니다. 학습/연구 또는 소액 테스트만 권장합니다.
|
||||
## 커뮤니티
|
||||
|
||||
---
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [라이브 대시보드](https://vergex.trade/explore)
|
||||
|
||||
## License
|
||||
## 기여
|
||||
|
||||
코드, 문서, 번역, 버그 리포트 모두 환영합니다 — [기여 가이드](../../../CONTRIBUTING.md), [행동 강령](../../../CODE_OF_CONDUCT.md), [보안 정책](../../../SECURITY.md)을 참고하세요.
|
||||
|
||||
NOFX는 의미 있는 기여를 기록하며, 생태계가 성장함에 따라 기여자에게 보상할 계획입니다. 우선순위 이슈에는 더 높은 가중치가 부여됩니다.
|
||||
|
||||
| 기여 유형 | 가중치 |
|
||||
| :---------------- | :----: |
|
||||
| 고정 이슈 PR | ★★★★★★ |
|
||||
| 코드(머지된 PR) | ★★★★★ |
|
||||
| 버그 수정 | ★★★★ |
|
||||
| 기능 아이디어 | ★★★ |
|
||||
| 버그 리포트 | ★★ |
|
||||
| 문서 | ★★ |
|
||||
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## 스폰서
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
[스폰서 되기](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
<br/>
|
||||
|
||||
NOFX가 도움이 되었다면, 스타 하나가 다른 트레이더들이 이 프로젝트를 발견하는 데 큰 힘이 됩니다.
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## 라이선스
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>자동매매에는 상당한 위험이 따릅니다. AI 기반 전략은 실험적이며 손실이 발생할 수 있습니다. 포지션 규모를 적절히 관리하고, 각 거래소의 구조를 이해하며, 잃어도 감당할 수 있는 자금으로만 거래하세요. 전체 [면책 조항](../../../DISCLAIMER.md)을 확인하세요.</sub>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<h1 align="center">NOFX</h1>
|
||||
<p align="center"><strong>При поддержке <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Ваш персональный AI торговый ассистент.</strong><br/>
|
||||
<strong>Любой рынок. Любая модель. Оплата USDC, без API ключей.</strong>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,146 +21,262 @@
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
NOFX — это **автономный** AI торговый ассистент с открытым исходным кодом. В отличие от традиционных AI инструментов, где нужно вручную настраивать модели, управлять API ключами и подключать источники данных — AI в NOFX **сам анализирует рынки, выбирает модели и получает данные**. Нулевое вмешательство человека. Вы задаёте стратегию, AI делает всё остальное.
|
||||
NOFX — торговый терминал с открытым исходным кодом, где стратегией выступает языковая модель. Каждый трейдер работает в непрерывном цикле — читает структуру рынка, принимает решение, исполняет его и записывает ход рассуждений, — а рантайм на Go прижимает каждый ордер к жёстким риск-лимитам, которые модель не может обойти.
|
||||
|
||||
**Полная автономность**: AI сам решает, какую модель использовать, какие рыночные данные получить, когда торговать. Без ручной настройки моделей. Без жонглирования API ключами разных сервисов. Просто пополните USDC кошелёк и запустите.
|
||||
|
||||
Ключевое отличие: **встроенные [x402](https://x402.org) микроплатежи**. Без API ключей. Пополните USDC кошелёк и платите за каждый запрос. Кошелёк — это ваша идентификация.
|
||||
Трейдеры собираются свободно: любая модель, любая из девяти бирж, любая стратегия. Запускайте несколько параллельно и сравнивайте их в публичной таблице лидеров по реализованной доходности. Всё работает на вашей собственной машине; биржевые ключи шифруются при хранении и никогда её не покидают.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Откройте **http://127.0.0.1:3000**. Готово.
|
||||
Терминал откроется по адресу `http://127.0.0.1:3000`.
|
||||
|
||||
---
|
||||
**Первый запуск**
|
||||
|
||||
## Как работает x402
|
||||
1. Зарегистрируйтесь — первый аккаунт становится владельцем экземпляра.
|
||||
2. Пройдите пошаговый запуск: положите **$1+ USDC** (сеть Base) в кошелёк для оплаты AI, который он создаст для вас, затем подключите Hyperliquid и внесите **$12+ USDC** для торговли.
|
||||
3. Запустите **Autopilot**. AI сканирует рынок каждые несколько минут и торгует самостоятельно; каждое решение появляется на дашборде в момент принятия. Остановить можно в любой момент одним кликом.
|
||||
|
||||
Традиционный процесс: регистрация → покупка кредитов → получение API ключа → управление квотой → ротация ключей.
|
||||
<br/>
|
||||
|
||||
x402 процесс:
|
||||
## Регистрация на биржах
|
||||
|
||||
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) |
|
||||
|
||||
<br/>
|
||||
|
||||
## Демо
|
||||
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
<br/>
|
||||
|
||||
## Модель предполагает. Рантайм располагает.
|
||||
|
||||
Решения принимает языковая модель, читающая стек данных [Claw402.ai](https://claw402.ai) · Vergex: живой сигнальный борд, ранжирующий каждый рынок по направлению и силе сигнала, глубокие сигналы Signal Lab по каждому инструменту, тепловые карты себестоимости и ликвидаций, показывающие, где у толпы «топливо» и «стены», и чистый поток средств в реальном времени — всё это сверяется с сырыми свечами и собственным живым трек-рекордом трейдера. Исполнение — нет.
|
||||
|
||||
Каждый ордер проходит через лимиты, зашитые в код и недоступные модели:
|
||||
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| Лимиты позиций | Максимум одновременных позиций, номинал ограничен долей от капитала, одна позиция на инструмент |
|
||||
| Ограничение плеча | Жёсткие пределы применяются при расчёте размера ордера, независимо от того, что запросила модель |
|
||||
| Защита на стороне биржи | Стоп-лосс и тейк-профит выставляются на бирже сразу после каждого входа |
|
||||
| Автозакрытие по просадке | Прибыльные позиции, отдавшие слишком много от своего пика, закрываются |
|
||||
| Троттлинг сделок | Минимальное время удержания, кулдауны повторного входа по инструменту, лимиты входов на цикл и на час |
|
||||
| Безопасный режим | Повторяющиеся сбои модели блокируют новые входы, пока модель не восстановится |
|
||||
| Предстартовая проверка | Доступ к модели, средства в кошельке, стратегия и балансы бирж проверяются до того, как трейдер сможет стартовать |
|
||||
|
||||
Каждое решение сохраняется вместе с полным ходом рассуждений модели. Ни одной позиции без документального следа.
|
||||
|
||||
<br/>
|
||||
|
||||
## Терминал
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Autopilot** | Пошаговый запуск: пополнить, подключить, внести депозит, стартовать — с серверной предстартовой проверкой на каждом шаге |
|
||||
| **Strategy Studio** | Пресеты стилей, наборы монет, индикаторы, плечо, порог уверенности для входа, собственные промпты |
|
||||
| **Competition** | Публичная таблица лидеров по реализованной доходности, каждая запись привязана к своей модели |
|
||||
| **Dashboard** | Живые позиции, ордера, статистика и обоснование каждого решения |
|
||||
|
||||
<details>
|
||||
<summary>Скриншоты</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Обзор | График рынка |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| Статистика торговли | История позиций |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| Редактор стратегий | Настройка индикаторов |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
| Соревнование | Конфигурация |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## Модели
|
||||
|
||||
Восемь провайдеров с вашими собственными ключами — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — включая пользовательские эндпоинты и имена моделей.
|
||||
|
||||
Или вовсе без ключей: [Claw402](https://claw402.ai) тарифицирует использование моделей за каждый вызов в USDC по протоколу x402. Кошелёк в сети Base заменяет все API-ключи.
|
||||
|
||||
| Провайдер | Доступ |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [AI-модели с оплатой по мере использования и официальной скидкой](https://claw402.ai) |
|
||||
|
||||
## Рынки
|
||||
|
||||
Криптовалютные бессрочные контракты на всех девяти биржах. На Hyperliquid тот же рантайм торгует также токенизированными акциями США, сырьевыми товарами, индексами, валютными парами и pre-IPO перпетуалами — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — наряду с криптовалютой.
|
||||
|
||||
<br/>
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
Запрос → 402 (вот цена) → кошелёк подписывает USDC → повтор → готово
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Без аккаунтов. Без API ключей. Без предоплаты. Один кошелёк, все модели.
|
||||
|
||||
### Встроенные x402 провайдеры
|
||||
|
||||
| Провайдер | Сеть | Модели |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ моделей |
|
||||
|
||||
---
|
||||
|
||||
## Возможности
|
||||
|
||||
| Функция | Описание |
|
||||
|:--------|:------------|
|
||||
| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — переключение в любой момент |
|
||||
| **Мульти-биржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Студия стратегий** | Визуальный конструктор — источники монет, индикаторы, контроль рисков |
|
||||
| **AI Арена дебатов** | Несколько AI обсуждают сделки (Бык vs Медведь vs Аналитик), голосуют, исполняют |
|
||||
| **AI Соревнование** | AI соревнуются в реальном времени, рейтинг в таблице лидеров |
|
||||
| **Telegram Агент** | Чат с торговым ассистентом — стриминг, вызов инструментов, память |
|
||||
| **Лаборатория бэктеста** | Историческая симуляция с кривой капитала и метриками |
|
||||
| **Панель управления** | Позиции в реальном времени, P/L, логи AI решений с Chain of Thought |
|
||||
|
||||
### Рынки
|
||||
|
||||
Криптовалюта · Акции США · Форекс · Металлы
|
||||
|
||||
### Биржи (CEX)
|
||||
|
||||
| Биржа | Статус | Регистрация (скидка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Регистрация](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Регистрация](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Регистрация](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Регистрация](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Регистрация](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Регистрация](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Биржи (Perp-DEX)
|
||||
|
||||
| Биржа | Статус | Регистрация (скидка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Регистрация](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Регистрация](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Регистрация](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI Модели (Режим API ключей)
|
||||
|
||||
| AI Модель | Статус | Получить API ключ |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Получить](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Получить](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Получить](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Получить](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Получить](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Получить](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Получить](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Получить](https://platform.minimaxi.com) |
|
||||
|
||||
### AI Модели (Режим x402 — без API ключей)
|
||||
|
||||
15+ моделей через [Claw402](https://claw402.ai) — только USDC кошелёк
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Установка
|
||||
|
||||
### Linux / macOS
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (Облако)
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Из исходников
|
||||
**Windows** — установите [Docker Desktop](https://www.docker.com/products/docker-desktop/), затем:
|
||||
|
||||
```bash
|
||||
# Требования: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # бэкенд
|
||||
cd web && npm install && npm run dev # фронтенд (новый терминал)
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
**Из исходников** — Go 1.21+, Node.js 18+:
|
||||
|
||||
## Ссылки
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
**Обновление** — повторно запустите скрипт установки; он обновит всё на месте.
|
||||
|
||||
<details>
|
||||
<summary>Развёртывание на сервере</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**HTTPS через Cloudflare**
|
||||
|
||||
1. Добавьте домен в [Cloudflare](https://dash.cloudflare.com) (бесплатный план)
|
||||
2. A-запись → IP сервера, с проксированием
|
||||
3. SSL/TLS → Flexible
|
||||
4. `TRANSPORT_ENCRYPTION=true` в `.env`
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## Документация
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| Сайт | [nofxai.com](https://nofxai.com) |
|
||||
| Панель | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API Документация | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [Начало работы](../../getting-started/README.md) | Гайды по развёртыванию и биржевым API |
|
||||
| [Архитектура](../../architecture/README.md) | Устройство системы и индекс модулей |
|
||||
| [Модуль стратегий](../../architecture/STRATEGY_MODULE.md) | Выбор монет, AI-промпты, исполнение |
|
||||
| [FAQ](../../guides/faq.en.md) | Частые вопросы |
|
||||
| [Устранение неполадок](../../guides/TROUBLESHOOTING.md) | Диагностика типичных проблем |
|
||||
|
||||
> **Предупреждение**: AI автоторговля несёт значительные риски. Рекомендуется только для обучения/исследований или тестирования малых сумм.
|
||||
## Сообщество
|
||||
|
||||
---
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [Живой дашборд](https://vergex.trade/explore)
|
||||
|
||||
## Участие в разработке
|
||||
|
||||
Код, документация, переводы и сообщения об ошибках — всё это приветствуется; см. [руководство для контрибьюторов](../../../CONTRIBUTING.md), [кодекс поведения](../../../CODE_OF_CONDUCT.md) и [политику безопасности](../../../SECURITY.md).
|
||||
|
||||
NOFX отслеживает значимые вклады и намерен вознаграждать контрибьюторов по мере роста экосистемы. Приоритетные issues имеют больший вес.
|
||||
|
||||
| Вклад | Вес |
|
||||
| :---------------- | :----: |
|
||||
| PR по закреплённым issues | ★★★★★★ |
|
||||
| Код (принятые PR) | ★★★★★ |
|
||||
| Исправления ошибок | ★★★★ |
|
||||
| Идеи новых функций | ★★★ |
|
||||
| Сообщения об ошибках | ★★ |
|
||||
| Документация | ★★ |
|
||||
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## Спонсоры
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
[Стать спонсором](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
<br/>
|
||||
|
||||
Если NOFX вам полезен, звезда помогает другим трейдерам его найти.
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## Лицензия
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>Автоматическая торговля сопряжена со значительным риском. Стратегии на основе AI экспериментальны и могут терять деньги. Разумно выбирайте размер позиций, разбирайтесь в устройстве каждой площадки и никогда не торгуйте средствами, потерю которых не можете себе позволить. Полный [дисклеймер](../../../DISCLAIMER.md).</sub>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<h1 align="center">NOFX</h1>
|
||||
<p align="center"><strong>За підтримки <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Ваш персональний AI торговий асистент.</strong><br/>
|
||||
<strong>Будь-який ринок. Будь-яка модель. Оплата USDC, без API ключів.</strong>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,146 +21,262 @@
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
NOFX — це **автономний** AI торговий асистент з відкритим кодом. На відміну від традиційних AI інструментів, де потрібно вручну налаштовувати моделі, керувати API ключами та підключати джерела даних — AI у NOFX **сам аналізує ринки, обирає моделі та отримує дані**. Нульове втручання людини. Ви задаєте стратегію, AI робить все інше.
|
||||
NOFX — торговий термінал з відкритим кодом, у якому стратегією є мовна модель. Кожен трейдер працює в безперервному циклі — читає структуру ринку, приймає рішення, виконує його, фіксує хід міркувань — а рантайм на Go обмежує кожен ордер жорсткими ризик-лімітами, які модель не може обійти.
|
||||
|
||||
**Повна автономність**: AI сам вирішує, яку модель використовувати, які ринкові дані отримати, коли торгувати. Без ручного налаштування моделей. Без жонглювання API ключами різних сервісів. Просто поповніть USDC гаманець і запустіть.
|
||||
|
||||
Ключова відмінність: **вбудовані [x402](https://x402.org) мікроплатежі**. Без API ключів. Поповніть USDC гаманець і платіть за кожен запит. Гаманець — це ваша ідентифікація.
|
||||
Трейдери компонуються вільно: будь-яка модель, будь-яка з дев'яти бірж, будь-яка стратегія. Запускайте кілька одночасно та порівнюйте їх у публічній таблиці лідерів за реалізованою дохідністю. Усе працює на вашій власній машині; облікові дані бірж зашифровані на диску й ніколи її не покидають.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Відкрийте **http://127.0.0.1:3000**. Готово.
|
||||
Термінал відкривається за адресою `http://127.0.0.1:3000`.
|
||||
|
||||
---
|
||||
**Перший запуск**
|
||||
|
||||
## Як працює x402
|
||||
1. Зареєструйтеся — перший акаунт стає власником інстансу.
|
||||
2. Пройдіть покроковий запуск: покладіть **$1+ USDC** (мережа Base) у гаманець для оплати AI, який він створить для вас, потім підключіть Hyperliquid і внесіть **$12+ USDC** для торгівлі.
|
||||
3. Запустіть **Autopilot**. AI сканує ринок кожні кілька хвилин і торгує самостійно; кожне рішення з'являється на дашборді в момент ухвалення. Зупинити його можна будь-коли одним кліком.
|
||||
|
||||
Традиційний процес: реєстрація → купівля кредитів → отримання API ключа → управління квотою → ротація ключів.
|
||||
<br/>
|
||||
|
||||
x402 процес:
|
||||
## Реєстрація на біржах
|
||||
|
||||
```
|
||||
Запит → 402 (ось ціна) → гаманець підписує USDC → повтор → готово
|
||||
```
|
||||
NOFX безкоштовний і має відкритий код. Відкриття акаунта за партнерськими посиланнями нижче дає знижку на торгові комісії та фінансує подальшу розробку.
|
||||
|
||||
Без акаунтів. Без API ключів. Без передоплати. Один гаманець, усі моделі.
|
||||
|
||||
### Вбудовані x402 провайдери
|
||||
|
||||
| Провайдер | Мережа | Моделі |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ моделей |
|
||||
|
||||
---
|
||||
|
||||
## Можливості
|
||||
|
||||
| Функція | Опис |
|
||||
|:--------|:------------|
|
||||
| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — перемикання будь-коли |
|
||||
| **Мульти-біржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Студія стратегій** | Візуальний конструктор — джерела монет, індикатори, контроль ризиків |
|
||||
| **AI Арена дебатів** | Кілька AI обговорюють угоди, голосують, виконують |
|
||||
| **AI Змагання** | AI змагаються в реальному часі, рейтинг у таблиці лідерів |
|
||||
| **Telegram Агент** | Чат з торговим асистентом — стрімінг, виклик інструментів, пам'ять |
|
||||
| **Лабораторія бектесту** | Історична симуляція з кривою капіталу та метриками |
|
||||
| **Панель управління** | Позиції в реальному часі, P/L, логи AI рішень з Chain of Thought |
|
||||
|
||||
### Ринки
|
||||
|
||||
Криптовалюта · Акції США · Форекс · Метали
|
||||
|
||||
### Біржі (CEX)
|
||||
|
||||
| Біржа | Статус | Реєстрація (знижка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| Біржа | Статус | Реєстрація зі знижкою на комісії |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Реєстрація](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Реєстрація](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Реєстрація](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Реєстрація](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Реєстрація](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Реєстрація](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Біржі (Perp-DEX)
|
||||
|
||||
| Біржа | Статус | Реєстрація (знижка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Реєстрація](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Реєстрація](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Реєстрація](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Реєстрація](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI Моделі (Режим API ключів)
|
||||
<br/>
|
||||
|
||||
| AI Модель | Статус | Отримати API ключ |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Отримати](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Отримати](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Отримати](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Отримати](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Отримати](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Отримати](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Отримати](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Отримати](https://platform.minimaxi.com) |
|
||||
## Демо
|
||||
|
||||
### AI Моделі (Режим x402 — без API ключів)
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
15+ моделей через [Claw402](https://claw402.ai) — лише USDC гаманець
|
||||
<br/>
|
||||
|
||||
---
|
||||
## Модель пропонує. Рантайм вирішує.
|
||||
|
||||
Рішення приймає мовна модель, що читає стек даних [Claw402.ai](https://claw402.ai) · Vergex: живу сигнальну панель, яка ранжує кожен ринок за напрямком і силою сигналу, глибокі сигнали Signal Lab по кожному інструменту, теплові карти собівартості та ліквідацій, що показують, де в натовпу «паливо» і «стіни», та чистий потік коштів у реальному часі — усе це звіряється із сирими свічками та власним живим трек-рекордом трейдера. Виконання — ні.
|
||||
|
||||
Кожен ордер проходить через ліміти, зашиті в код поза досяжністю моделі:
|
||||
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| Ліміти позицій | Максимум одночасних позицій, номінал обмежено часткою від капіталу, одна позиція на символ |
|
||||
| Обмеження плеча | Жорсткі стелі застосовуються під час розрахунку розміру ордера, незалежно від запиту моделі |
|
||||
| Захист на боці біржі | Стоп-лос і тейк-профіт розміщуються на біржі одразу після кожного входу |
|
||||
| Автозакриття за просадкою | Прибуткові позиції, що віддають надто багато від свого піку, закриваються |
|
||||
| Обмеження частоти угод | Мінімальний час утримання, кулдауни на повторний вхід за символом, ліміти входів на цикл і на годину |
|
||||
| Безпечний режим | Повторювані збої моделі блокують нові входи, доки модель не відновиться |
|
||||
| Передстартова перевірка | Доступ до моделі, кошти в гаманці, стратегія та баланси бірж перевіряються, перш ніж трейдер зможе стартувати |
|
||||
|
||||
Кожне рішення зберігається разом із повним ходом міркувань моделі. Жодної позиції без документального сліду.
|
||||
|
||||
<br/>
|
||||
|
||||
## Термінал
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Autopilot** | Покроковий запуск: поповнення, підключення, депозит, старт — із серверною передстартовою перевіркою на кожному кроці |
|
||||
| **Strategy Studio** | Пресети стилів, набори монет, індикатори, плече, впевненість входу, власні промпти |
|
||||
| **Змагання** | Публічна таблиця лідерів за реалізованою дохідністю; кожен запис прив'язаний до своєї моделі |
|
||||
| **Дашборд** | Живі позиції, ордери, статистика та обґрунтування кожного рішення |
|
||||
|
||||
<details>
|
||||
<summary>Скріншоти</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Огляд | Графік ринку |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| Статистика торгівлі | Історія позицій |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| Редактор стратегій | Налаштування індикаторів |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
| Змагання | Конфігурація |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## Моделі
|
||||
|
||||
Вісім провайдерів із вашими власними ключами — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — включно з власними ендпоінтами та назвами моделей.
|
||||
|
||||
Або взагалі без ключів: [Claw402](https://claw402.ai) тарифікує використання моделей за кожен виклик у USDC через протокол x402. Гаманець у мережі Base замінює всі API-ключі.
|
||||
|
||||
| Провайдер | Доступ |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [AI-моделі з оплатою за використання та офіційною знижкою](https://claw402.ai) |
|
||||
|
||||
## Ринки
|
||||
|
||||
Криптовалютні безстрокові контракти на всіх дев'яти біржах. На Hyperliquid той самий рантайм також торгує токенізованими акціями США, сировинними товарами, індексами, валютами та pre-IPO перпетуалами — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — поряд із криптою.
|
||||
|
||||
<br/>
|
||||
|
||||
## Архітектура
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
<br/>
|
||||
|
||||
## Встановлення
|
||||
|
||||
### Linux / macOS
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (Хмара)
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### З вихідного коду
|
||||
**Windows** — встановіть [Docker Desktop](https://www.docker.com/products/docker-desktop/), потім:
|
||||
|
||||
```bash
|
||||
# Вимоги: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # бекенд
|
||||
cd web && npm install && npm run dev # фронтенд (новий термінал)
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
**З вихідного коду** — Go 1.21+, Node.js 18+:
|
||||
|
||||
## Посилання
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
**Оновлення** — запустіть інсталяційний скрипт ще раз; він оновить усе на місці.
|
||||
|
||||
<details>
|
||||
<summary>Розгортання на сервері</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**HTTPS через Cloudflare**
|
||||
|
||||
1. Додайте домен у [Cloudflare](https://dash.cloudflare.com) (безкоштовний план)
|
||||
2. A-запис → IP сервера, проксійований
|
||||
3. SSL/TLS → Flexible
|
||||
4. `TRANSPORT_ENCRYPTION=true` у `.env`
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## Документація
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| Сайт | [nofxai.com](https://nofxai.com) |
|
||||
| Панель | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API Документація | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [Початок роботи](../../getting-started/README.md) | Посібники з розгортання та біржових API |
|
||||
| [Архітектура](../../architecture/README.md) | Дизайн системи та індекс модулів |
|
||||
| [Модуль стратегій](../../architecture/STRATEGY_MODULE.md) | Вибір монет, AI-промпти, виконання |
|
||||
| [FAQ](../../guides/faq.en.md) | Поширені запитання |
|
||||
| [Усунення несправностей](../../guides/TROUBLESHOOTING.md) | Діагностика типових проблем |
|
||||
|
||||
> **Попередження**: AI автоторгівля несе значні ризики. Рекомендується лише для навчання/досліджень або тестування малих сум.
|
||||
## Спільнота
|
||||
|
||||
---
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [Живий дашборд](https://vergex.trade/explore)
|
||||
|
||||
## Участь у розробці
|
||||
|
||||
Код, документація, переклади та звіти про помилки — усе це вітається; див. [Посібник для контриб'юторів](../../../CONTRIBUTING.md), [Кодекс поведінки](../../../CODE_OF_CONDUCT.md) і [Політику безпеки](../../../SECURITY.md).
|
||||
|
||||
NOFX відстежує значущі внески й має намір винагороджувати контриб'юторів у міру зростання екосистеми. Пріоритетні issues мають більшу вагу.
|
||||
|
||||
| Внесок | Вага |
|
||||
| :---------------- | :----: |
|
||||
| PR до закріплених issues | ★★★★★★ |
|
||||
| Код (змерджені PR) | ★★★★★ |
|
||||
| Виправлення багів | ★★★★ |
|
||||
| Ідеї функцій | ★★★ |
|
||||
| Звіти про баги | ★★ |
|
||||
| Документація | ★★ |
|
||||
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## Спонсори
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
[Стати спонсором](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
<br/>
|
||||
|
||||
Якщо NOFX вам корисний, зірка допоможе іншим трейдерам його знайти.
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## Ліцензія
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>Автоматизована торгівля пов'язана зі значним ризиком. Стратегії на основі AI експериментальні й можуть втрачати гроші. Обирайте розмір позицій розважливо, розумійте кожен майданчик і ніколи не торгуйте коштами, втрату яких не можете собі дозволити. Повний [дисклеймер](../../../DISCLAIMER.md).</sub>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<h1 align="center">NOFX</h1>
|
||||
<p align="center"><strong>Được hậu thuẫn bởi <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Trợ lý giao dịch AI cá nhân của bạn.</strong><br/>
|
||||
<strong>Mọi thị trường. Mọi mô hình. Thanh toán USDC, không cần API key.</strong>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,144 +21,262 @@
|
||||
<a href="README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
NOFX là trợ lý giao dịch AI **tự chủ** mã nguồn mở. Không giống các công cụ AI truyền thống yêu cầu bạn cấu hình mô hình thủ công, quản lý API key và kết nối nguồn dữ liệu — AI của NOFX **tự nhận diện thị trường, tự chọn mô hình và tự lấy dữ liệu**. Không cần con người can thiệp. Bạn chỉ cần đặt chiến lược, AI xử lý mọi thứ còn lại.
|
||||
NOFX là một terminal giao dịch mã nguồn mở, nơi chiến lược chính là một mô hình ngôn ngữ. Mỗi trader chạy một vòng lặp liên tục — đọc cấu trúc thị trường, ra quyết định, thực thi, ghi lại lập luận — trong khi một runtime viết bằng Go ép mọi lệnh vào các giới hạn rủi ro cứng mà mô hình không thể vượt qua.
|
||||
|
||||
**Hoàn toàn tự chủ**: AI tự quyết định sử dụng mô hình nào, lấy dữ liệu thị trường gì, khi nào giao dịch. Không cần cấu hình mô hình thủ công. Không cần quản lý API key của nhiều dịch vụ. Chỉ cần nạp ví USDC và chạy.
|
||||
|
||||
Điểm khác biệt: **tích hợp thanh toán vi mô [x402](https://x402.org)**. Không cần API key. Nạp ví USDC và thanh toán theo yêu cầu. Ví chính là danh tính của bạn.
|
||||
Trader được kết hợp tự do: bất kỳ mô hình nào, bất kỳ sàn nào trong chín sàn, bất kỳ chiến lược nào. Chạy nhiều trader song song và so sánh chúng trên bảng xếp hạng công khai theo lợi nhuận đã thực hiện. Mọi thứ chạy trên máy của chính bạn; thông tin xác thực sàn được mã hóa khi lưu trữ và không bao giờ rời khỏi máy.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Mở **http://127.0.0.1:3000**. Xong.
|
||||
Terminal mở tại `http://127.0.0.1:3000`.
|
||||
|
||||
---
|
||||
**Lần chạy đầu tiên**
|
||||
|
||||
## x402 hoạt động như thế nào
|
||||
1. Đăng ký — tài khoản đầu tiên trở thành chủ sở hữu của instance.
|
||||
2. Làm theo quy trình khởi chạy có hướng dẫn: nạp **$1+ USDC** (mạng Base) vào ví phí AI được tạo sẵn cho bạn, sau đó kết nối Hyperliquid và nạp **$12+ USDC** để giao dịch.
|
||||
3. Khởi động **Autopilot**. AI quét thị trường vài phút một lần và tự mình giao dịch; mọi quyết định hiện lên dashboard ngay khi diễn ra. Dừng lại bất cứ lúc nào chỉ với một cú nhấp.
|
||||
|
||||
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.
|
||||
<br/>
|
||||
|
||||
Quy trình x402:
|
||||
## Đăng ký sàn giao dịch
|
||||
|
||||
```
|
||||
Yêu cầu → 402 (đây là giá) → ví ký USDC → thử lại → xong
|
||||
```
|
||||
NOFX miễn phí và mã nguồn mở. Mở tài khoản qua các liên kết đối tác bên dưới sẽ được giảm phí giao dịch và góp phần tài trợ cho việc phát triển lâu dài.
|
||||
|
||||
Không tài khoản. Không API key. Không trả trước. Một ví, tất cả mô hình.
|
||||
|
||||
### Nhà cung cấp x402 tích hợp
|
||||
|
||||
| Nhà cung cấp | Chain | Mô hình |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ mô hình |
|
||||
|
||||
---
|
||||
|
||||
## Tính năng
|
||||
|
||||
| Tính năng | Mô tả |
|
||||
|:--------|:------------|
|
||||
| **Đa AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — chuyển đổi bất cứ lúc nào |
|
||||
| **Đa Sàn** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Strategy Studio** | Trình xây dựng trực quan — nguồn coin, chỉ báo, kiểm soát rủi ro |
|
||||
| **AI Competition** | AI cạnh tranh thời gian thực, bảng xếp hạng hiệu suất |
|
||||
| **Telegram Agent** | Chat với trợ lý giao dịch — streaming, gọi công cụ, bộ nhớ |
|
||||
| **Dashboard** | Vị thế trực tiếp, P/L, nhật ký quyết định AI với Chain of Thought |
|
||||
|
||||
### Thị trường
|
||||
|
||||
Crypto · Cổ phiếu Mỹ · Forex · Kim loại
|
||||
|
||||
### Sàn giao dịch (CEX)
|
||||
|
||||
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| Sàn giao dịch | Trạng thái | Đăng ký kèm ưu đãi phí |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Đăng ký](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Đăng ký](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Đăng ký](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Đăng ký](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Đăng ký](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Đăng ký](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Đăng ký](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Sàn giao dịch (Perp-DEX)
|
||||
|
||||
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Đăng ký](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Đăng ký](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Đăng ký](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Đăng ký](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### Mô hình AI (Chế độ API Key)
|
||||
<br/>
|
||||
|
||||
| Mô hình AI | Trạng thái | Lấy API Key |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Lấy API Key](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Lấy API Key](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Lấy API Key](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Lấy API Key](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Lấy API Key](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Lấy API Key](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Lấy API Key](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Lấy API Key](https://platform.minimaxi.com) |
|
||||
## Demo
|
||||
|
||||
### Mô hình AI (Chế độ x402 — Không cần API Key)
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
15+ mô hình qua [Claw402](https://claw402.ai) — chỉ cần ví USDC
|
||||
<br/>
|
||||
|
||||
---
|
||||
## Mô hình đề xuất. Runtime định đoạt.
|
||||
|
||||
Các quyết định đến từ một mô hình ngôn ngữ đọc bộ dữ liệu [Claw402.ai](https://claw402.ai) · Vergex: bảng tín hiệu trực tiếp xếp hạng mọi thị trường theo thiên hướng và độ mạnh tín hiệu, tín hiệu chuyên sâu Signal Lab cho từng mã, bản đồ nhiệt chi phí vốn và thanh lý cho thấy "nhiên liệu" và "bức tường" của đám đông nằm ở đâu, cùng dòng tiền ròng theo thời gian thực — tất cả được đối chiếu với nến thô và thành tích thực chiến của chính trader. Việc thực thi thì không.
|
||||
|
||||
Mọi lệnh đều phải đi qua các giới hạn được thực thi bằng code, nằm ngoài tầm với của mô hình:
|
||||
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| Giới hạn vị thế | Số vị thế đồng thời tối đa, giá trị danh nghĩa bị chặn theo tỷ lệ vốn, mỗi symbol chỉ một vị thế |
|
||||
| Kẹp đòn bẩy | Trần cứng áp dụng ngay khi tính khối lượng lệnh, bất kể mô hình yêu cầu gì |
|
||||
| Bảo vệ phía sàn | Stop-loss và take-profit được đặt trên sàn ngay sau mỗi lần vào lệnh |
|
||||
| Tự đóng khi rút vốn từ đỉnh | Vị thế đang lãi nhưng để mất quá nhiều so với đỉnh sẽ bị đóng |
|
||||
| Điều tiết giao dịch | Thời gian giữ tối thiểu, thời gian chờ vào lại theo từng symbol, giới hạn số lần vào lệnh theo chu kỳ và theo giờ |
|
||||
| Chế độ an toàn | Mô hình lỗi liên tiếp sẽ bị chặn vào lệnh mới cho đến khi mô hình hồi phục |
|
||||
| Kiểm tra tiền khởi chạy | Quyền truy cập mô hình, tiền trong ví, chiến lược và số dư sàn được xác minh trước khi trader được phép khởi động |
|
||||
|
||||
Mỗi quyết định được lưu kèm toàn bộ lập luận của mô hình. Không có vị thế nào thiếu hồ sơ lưu vết.
|
||||
|
||||
<br/>
|
||||
|
||||
## Terminal
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Autopilot** | Khởi chạy có hướng dẫn: nạp phí, kết nối, nạp tiền, khởi động — với kiểm tra preflight phía máy chủ xuyên suốt |
|
||||
| **Strategy Studio** | Preset phong cách, tập coin, chỉ báo, đòn bẩy, độ tin cậy vào lệnh, prompt tùy chỉnh |
|
||||
| **Cạnh tranh** | Bảng xếp hạng công khai theo lợi nhuận đã thực hiện, mỗi mục được ghi nhận cho mô hình tạo ra nó |
|
||||
| **Dashboard** | Vị thế trực tiếp, lệnh, thống kê và lập luận đằng sau mỗi quyết định |
|
||||
|
||||
<details>
|
||||
<summary>Ảnh chụp màn hình</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Tổng quan | Biểu đồ thị trường |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| Thống kê giao dịch | Lịch sử vị thế |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| Trình soạn chiến lược | Cấu hình chỉ báo |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
| Cạnh tranh | Cấu hình |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## Mô hình
|
||||
|
||||
Tám nhà cung cấp với key của riêng bạn — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — bao gồm cả endpoint và tên mô hình tùy chỉnh.
|
||||
|
||||
Hoặc không cần key nào cả: [Claw402](https://claw402.ai) tính phí sử dụng mô hình theo từng lần gọi bằng USDC qua giao thức x402. Một chiếc ví trên Base thay thế mọi API key.
|
||||
|
||||
| Nhà cung cấp | Truy cập |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [Mô hình AI trả theo mức dùng với ưu đãi chính thức](https://claw402.ai) |
|
||||
|
||||
## Thị trường
|
||||
|
||||
Hợp đồng vĩnh cửu crypto trên cả chín sàn. Trên Hyperliquid, cùng một runtime còn giao dịch cổ phiếu Mỹ token hóa, hàng hóa, chỉ số, ngoại hối và hợp đồng vĩnh cửu pre-IPO — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — song song với crypto.
|
||||
|
||||
<br/>
|
||||
|
||||
## Kiến trúc
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
<br/>
|
||||
|
||||
## Cài đặt
|
||||
|
||||
### Linux / macOS
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (Cloud)
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Từ mã nguồn
|
||||
**Windows** — cài [Docker Desktop](https://www.docker.com/products/docker-desktop/), sau đó:
|
||||
|
||||
```bash
|
||||
# Yêu cầu: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend (terminal mới)
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
**Từ mã nguồn** — Go 1.21+, Node.js 18+:
|
||||
|
||||
## Liên kết
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
**Cập nhật** — chạy lại script cài đặt; nó tự nâng cấp tại chỗ.
|
||||
|
||||
<details>
|
||||
<summary>Triển khai lên máy chủ</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**HTTPS qua Cloudflare**
|
||||
|
||||
1. Thêm domain vào [Cloudflare](https://dash.cloudflare.com) (gói miễn phí)
|
||||
2. Bản ghi A → IP máy chủ, bật proxy
|
||||
3. SSL/TLS → Flexible
|
||||
4. `TRANSPORT_ENCRYPTION=true` trong `.env`
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## Tài liệu
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| Website | [nofxai.com](https://nofxai.com) |
|
||||
| Dashboard | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API Docs | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [Bắt đầu](../../getting-started/README.md) | Hướng dẫn triển khai và API sàn |
|
||||
| [Kiến trúc](../../architecture/README.md) | Thiết kế hệ thống và chỉ mục module |
|
||||
| [Module chiến lược](../../architecture/STRATEGY_MODULE.md) | Chọn coin, prompt AI, thực thi |
|
||||
| [FAQ](../../guides/faq.en.md) | Câu hỏi thường gặp |
|
||||
| [Khắc phục sự cố](../../guides/TROUBLESHOOTING.md) | Chẩn đoán các sự cố thường gặp |
|
||||
|
||||
> **Cảnh báo rủi ro**: Giao dịch tự động AI có rủi ro đáng kể. Chỉ nên sử dụng cho mục đích học tập/nghiên cứu hoặc số tiền nhỏ.
|
||||
## Cộng đồng
|
||||
|
||||
---
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [Dashboard trực tiếp](https://vergex.trade/explore)
|
||||
|
||||
## License
|
||||
## Đóng góp
|
||||
|
||||
Code, tài liệu, bản dịch và báo cáo lỗi đều được hoan nghênh — xem [Hướng dẫn đóng góp](../../../CONTRIBUTING.md), [Quy tắc ứng xử](../../../CODE_OF_CONDUCT.md) và [Chính sách bảo mật](../../../SECURITY.md).
|
||||
|
||||
NOFX theo dõi các đóng góp có ý nghĩa và dự định thưởng cho người đóng góp khi hệ sinh thái phát triển. Issue ưu tiên có trọng số cao hơn.
|
||||
|
||||
| Đóng góp | Trọng số |
|
||||
| :---------------- | :----: |
|
||||
| PR cho issue được ghim | ★★★★★★ |
|
||||
| Code (PR đã merge) | ★★★★★ |
|
||||
| Sửa lỗi | ★★★★ |
|
||||
| Ý tưởng tính năng | ★★★ |
|
||||
| Báo cáo lỗi | ★★ |
|
||||
| Tài liệu | ★★ |
|
||||
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## Nhà tài trợ
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
[Trở thành nhà tài trợ](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
<br/>
|
||||
|
||||
Nếu NOFX hữu ích với bạn, một ngôi sao sẽ giúp các trader khác tìm thấy nó.
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## Giấy phép
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>Giao dịch tự động tiềm ẩn rủi ro đáng kể. Các chiến lược do AI điều khiển mang tính thử nghiệm và có thể thua lỗ. Hãy đặt kích thước vị thế hợp lý, hiểu rõ từng sàn giao dịch, và đừng bao giờ giao dịch bằng số tiền mà bạn không thể để mất. Xem đầy đủ [tuyên bố miễn trừ trách nhiệm](../../../DISCLAIMER.md).</sub>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<h1 align="center">NOFX</h1>
|
||||
<p align="center"><strong>由 <a href="https://vergex.trade">vergex.trade</a> 支持</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<strong>你的个人 AI 交易助手。</strong><br/>
|
||||
<strong>任何市场。任何模型。用 USDC 付费,无需 API Key。</strong>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,210 +21,262 @@
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
> **语言声明:** 本中文版本文档仅为方便海外华人社区阅读而提供,不代表本软件面向中国大陆、香港、澳门或台湾地区用户开放。如您位于上述地区,请勿使用本软件。
|
||||
<br/>
|
||||
|
||||
---
|
||||
NOFX 是一个开源交易终端,策略本身就是一个语言模型。每个交易员运行一个持续循环——读取市场结构、做出决策、执行、记录推理过程——同时由 Go 运行时把每一笔订单钳制在模型无法越过的硬性风控限制之内。
|
||||
|
||||
NOFX 是一个开源的**自主式** AI 交易助手。与需要手动配置模型、管理 API Key、接入数据源的传统 AI 工具不同 —— NOFX 的 AI **自主感知市场、自选模型、自动获取数据**。零人工干预。你只需设定策略,AI 负责一切。
|
||||
|
||||
**完全自主**:AI 自行决定使用哪个模型、获取什么市场数据、何时交易。无需手动配置模型,无需管理各种服务的 API Key。只需充值 USDC 钱包,一键启动。
|
||||
|
||||
核心差异:**内置 [x402](https://x402.org) 微支付协议**。无需 API Key,充值 USDC 钱包即可按需付费。钱包就是你的身份。
|
||||
交易员可以自由组合:任意模型、九家交易所任选、任意策略。多个交易员并行运行,在公开排行榜上按已实现收益一较高下。一切都在你自己的机器上运行;交易所凭证加密存储,绝不外传。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
打开 **http://127.0.0.1:3000**,完成。
|
||||
终端会在 `http://127.0.0.1:3000` 打开。
|
||||
|
||||
---
|
||||
**首次运行**
|
||||
|
||||
## x402 如何工作
|
||||
1. 注册——第一个账户即成为该实例的所有者。
|
||||
2. 按引导式启动流程操作:向系统为你创建的 AI 费用钱包存入 **$1+ USDC**(Base 网络),然后连接 Hyperliquid 并存入 **$12+ USDC** 作为交易资金。
|
||||
3. 启动 **Autopilot**。AI 每隔几分钟扫描一次市场并自主交易;每一个决策都会实时出现在仪表板上。随时可以一键停止。
|
||||
|
||||
传统流程:注册账号 → 购买额度 → 获取 API Key → 管理配额 → 轮换密钥。
|
||||
<br/>
|
||||
|
||||
x402 流程:
|
||||
## 注册交易所
|
||||
|
||||
```
|
||||
请求 → 402(返回价格)→ 钱包签名 USDC → 重试 → 完成
|
||||
```
|
||||
NOFX 免费且开源。通过下方合作伙伴链接开户可享受更低的交易手续费,同时也为项目的持续开发提供支持。
|
||||
|
||||
无需注册。无需 API Key。无需预付费。一个钱包,所有模型。
|
||||
|
||||
### 内置 x402 提供商
|
||||
|
||||
| 提供商 | 链 | 模型 |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4、Claude Opus、DeepSeek、Qwen、Grok、Gemini、Kimi — 15+ 模型 |
|
||||
|
||||
---
|
||||
|
||||
## 功能概览
|
||||
|
||||
| 功能 | 描述 |
|
||||
|:--------|:------------|
|
||||
| **多 AI** | DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi、MiniMax — 随时切换 |
|
||||
| **多交易所** | Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster、Lighter |
|
||||
| **策略工作室** | 可视化构建器 — 币种来源、指标、风控 |
|
||||
| **AI 竞赛** | AI 实时竞争,排行榜排名 |
|
||||
| **Telegram Agent** | 与交易助手对话 — 流式输出、工具调用、记忆 |
|
||||
| **回测实验室** | 历史模拟,权益曲线和性能指标 |
|
||||
| **仪表板** | 实时持仓、盈亏、AI 决策日志与思维链 |
|
||||
|
||||
### 市场
|
||||
|
||||
加密货币 · 美股 · 外汇 · 贵金属
|
||||
|
||||
### 交易所 (CEX)
|
||||
|
||||
| 交易所 | 状态 | 注册 (手续费折扣) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| 交易所 | 状态 | 享手续费折扣注册 |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [注册](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [注册](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [注册](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [注册](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [注册](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [注册](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### 交易所 (Perp-DEX)
|
||||
|
||||
| 交易所 | 状态 | 注册 (手续费折扣) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [注册](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [注册](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [注册](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [注册](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI 模型 (API Key 模式)
|
||||
<br/>
|
||||
|
||||
| AI 模型 | 状态 | 获取 API Key |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [获取 API Key](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **通义千问** | ✅ | [获取 API Key](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [获取 API Key](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [获取 API Key](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [获取 API Key](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [获取 API Key](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [获取 API Key](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [获取 API Key](https://platform.minimaxi.com) |
|
||||
## 演示
|
||||
|
||||
### AI 模型 (x402 模式 — 无需 API Key)
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
15+ 模型通过 [Claw402](https://claw402.ai) 接入 — 只需一个 USDC 钱包
|
||||
<br/>
|
||||
|
||||
---
|
||||
## 模型提议,运行时裁决
|
||||
|
||||
决策来自一个读取 [Claw402.ai](https://claw402.ai) · Vergex 数据栈的语言模型:实时信号看板为每个市场标注方向偏置与信号强度排名,Signal Lab 提供逐标的深度信号,成本与清算热力图揭示市场的"燃料"与"墙"在哪里,再叠加实时资金净流——并与原始 K 线和交易员自身的实盘战绩交叉验证。但执行不由它说了算。
|
||||
|
||||
每一笔订单都要经过代码层面强制执行的限制,模型无从干预:
|
||||
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| 持仓限制 | 最大并发持仓数、名义价值按权益比例封顶、每个币种仅允许一个持仓 |
|
||||
| 杠杆钳制 | 在订单定量时施加硬上限,与模型请求的杠杆无关 |
|
||||
| 交易所端保护 | 每次开仓后立即在交易所挂出止损和止盈单 |
|
||||
| 回撤自动平仓 | 盈利持仓从峰值回吐过多时会被自动平掉 |
|
||||
| 交易节流 | 最短持仓时间、单币种再入场冷却、单周期与单小时开仓次数限制 |
|
||||
| 安全模式 | 模型连续失败时阻止新开仓,直至模型恢复正常 |
|
||||
| 启动预检 | 交易员启动前须通过模型访问、钱包资金、策略和交易所余额的校验 |
|
||||
|
||||
每个决策都连同模型的完整推理一起存档。没有任何持仓是无据可查的。
|
||||
|
||||
<br/>
|
||||
|
||||
## 终端
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Autopilot** | 引导式启动:注资、连接、入金、启动——全程由服务端预检保驾护航 |
|
||||
| **Strategy Studio** | 风格预设、币种池、技术指标、杠杆、开仓置信度、自定义提示词 |
|
||||
| **竞赛** | 按已实现收益排名的公开排行榜,每个条目都标注所用模型 |
|
||||
| **仪表板** | 实时持仓、订单、统计数据,以及每个决策背后的推理 |
|
||||
|
||||
<details>
|
||||
<summary>截图</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| 概览 | 行情图表 |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| 交易统计 | 持仓历史 |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| 策略编辑器 | 指标配置 |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
| 竞赛 | 配置 |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## 模型
|
||||
|
||||
八家提供商,使用你自己的密钥——DeepSeek、OpenAI、Claude、Qwen、Gemini、Grok、Kimi、MiniMax——并支持自定义端点和模型名称。
|
||||
|
||||
或者完全不需要密钥:[Claw402](https://claw402.ai) 通过 x402 协议以 USDC 按次计量模型用量。一个 Base 链上的钱包即可替代所有 API 密钥。
|
||||
|
||||
| 提供商 | 接入方式 |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [按量付费的 AI 模型,享官方折扣](https://claw402.ai) |
|
||||
|
||||
## 市场
|
||||
|
||||
九家交易所全部支持加密货币永续合约。在 Hyperliquid 上,同一套运行时还可以交易代币化美股、大宗商品、指数、外汇和 pre-IPO 永续合约——TSLA、NVDA、GOLD、SPX、EUR、OPENAI——与加密资产并行。
|
||||
|
||||
<br/>
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
<br/>
|
||||
|
||||
## 安装
|
||||
|
||||
### Linux / macOS
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (云部署)
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Docker
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
安装 [Docker Desktop](https://www.docker.com/products/docker-desktop/),然后:
|
||||
**Windows** —— 安装 [Docker Desktop](https://www.docker.com/products/docker-desktop/),然后:
|
||||
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 从源码构建
|
||||
**从源码构建** —— 需要 Go 1.21+、Node.js 18+:
|
||||
|
||||
```bash
|
||||
# 前置条件: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # 后端
|
||||
cd web && npm install && npm run dev # 前端 (新终端)
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
### 更新
|
||||
**更新** —— 重新运行安装脚本,即可原地升级。
|
||||
|
||||
<details>
|
||||
<summary>服务器部署</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
---
|
||||
**通过 Cloudflare 启用 HTTPS**
|
||||
|
||||
## 配置
|
||||
1. 将域名添加到 [Cloudflare](https://dash.cloudflare.com)(免费套餐即可)
|
||||
2. A 记录 → 服务器 IP,开启代理
|
||||
3. SSL/TLS → Flexible
|
||||
4. 在 `.env` 中设置 `TRANSPORT_ENCRYPTION=true`
|
||||
|
||||
**新手模式**:首次使用的用户可以在注册时选择新手模式,系统会引导你逐步完成 AI、交易所和策略的配置。
|
||||
</details>
|
||||
|
||||
**进阶模式**:
|
||||
|
||||
1. **AI** — 添加 API Key 或配置 x402 钱包
|
||||
2. **交易所** — 连接交易所 API 凭证
|
||||
3. **策略** — 在策略工作室构建
|
||||
4. **交易员** — 组合 AI + 交易所 + 策略
|
||||
5. **交易** — 从仪表板启动
|
||||
|
||||
所有操作通过 Web 界面完成:**http://127.0.0.1:3000**
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## 文档
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| [架构概览](../../architecture/README.md) | 系统设计和模块索引 |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [快速开始](../../getting-started/README.zh-CN.md) | 部署与交易所 API 指南 |
|
||||
| [架构](../../architecture/README.md) | 系统设计与模块索引 |
|
||||
| [策略模块](../../architecture/STRATEGY_MODULE.md) | 币种选择、AI 提示词、执行 |
|
||||
| [常见问题](../../faq/README.md) | FAQ |
|
||||
| [快速开始](../../getting-started/README.md) | 部署指南 |
|
||||
| [常见问题](../../guides/faq.zh-CN.md) | 常见疑问解答 |
|
||||
| [故障排查](../../guides/TROUBLESHOOTING.zh-CN.md) | 常见问题诊断 |
|
||||
|
||||
---
|
||||
## 社区
|
||||
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [实时仪表板](https://vergex.trade/explore)
|
||||
|
||||
## 贡献
|
||||
|
||||
查看 [贡献指南](../../../CONTRIBUTING.md) · [行为准则](../../../CODE_OF_CONDUCT.md) · [安全政策](../../../SECURITY.md)
|
||||
代码、文档、翻译和 Bug 报告都欢迎——参见[贡献指南](../../../CONTRIBUTING.md)、[行为准则](../../../CODE_OF_CONDUCT.md)和[安全政策](../../../SECURITY.md)。
|
||||
|
||||
### 贡献者空投计划
|
||||
|
||||
所有贡献在 GitHub 上追踪。当 NOFX 产生收入时,贡献者将获得空投。
|
||||
|
||||
**解决 [置顶 Issue](https://github.com/NoFxAiOS/nofx/issues) 的 PR 获得最高奖励!**
|
||||
NOFX 会记录有价值的贡献,并计划随着生态发展回馈贡献者。优先级 Issue 拥有更高权重。
|
||||
|
||||
| 贡献类型 | 权重 |
|
||||
|:-------------|:------:|
|
||||
| 置顶 Issue PR | ★★★★★★ |
|
||||
| 代码提交 (合并的 PR) | ★★★★★ |
|
||||
| :---------------- | :----: |
|
||||
| 置顶 Issue 的 PR | ★★★★★★ |
|
||||
| 代码(已合并 PR) | ★★★★★ |
|
||||
| Bug 修复 | ★★★★ |
|
||||
| 功能建议 | ★★★ |
|
||||
| Bug 报告 | ★★ |
|
||||
| 文档 | ★★ |
|
||||
|
||||
---
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## 链接
|
||||
## 赞助者
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| 官网 | [nofxai.com](https://nofxai.com) |
|
||||
| 数据面板 | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API 文档 | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
> **风险提示**: AI 自动交易存在重大风险。建议仅用于学习/研究或小额测试。
|
||||
[成为赞助者](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## License
|
||||
如果 NOFX 对你有帮助,点个 Star 能让更多交易者发现它。
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## 许可证
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>自动化交易存在重大风险。AI 驱动的策略尚处实验阶段,可能造成亏损。请合理控制仓位规模,充分了解每个交易场所,切勿投入无法承受损失的资金。完整[免责声明](../../../DISCLAIMER.md)。</sub>
|
||||
|
||||
91
go.mod
91
go.mod
@@ -1,129 +1,104 @@
|
||||
module nofx
|
||||
|
||||
go 1.25.3
|
||||
go 1.25.11
|
||||
|
||||
require (
|
||||
github.com/adshao/go-binance/v2 v2.8.9
|
||||
github.com/agiledragon/gomonkey/v2 v2.13.0
|
||||
github.com/ethereum/go-ethereum v1.16.7
|
||||
github.com/antihax/optional v1.0.0
|
||||
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6
|
||||
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48
|
||||
github.com/ethereum/go-ethereum v1.17.3
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/sonirico/go-hyperliquid v0.26.0
|
||||
github.com/sonirico/go-hyperliquid v0.36.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.42.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/text v0.37.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
modernc.org/sqlite v1.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.0.0-rc.1 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect
|
||||
github.com/antihax/optional v1.0.0 // indirect
|
||||
github.com/armon/go-radix v1.0.0 // indirect
|
||||
github.com/bitly/go-simplejson v0.5.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
||||
github.com/blendle/zapdriver v1.3.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/consensys/gnark-crypto v0.19.0 // indirect
|
||||
github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
|
||||
github.com/consensys/gnark-crypto v0.19.2 // indirect
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elastic/go-sysinfo v1.15.4 // indirect
|
||||
github.com/elastic/go-windows v1.0.2 // indirect
|
||||
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48 // indirect
|
||||
github.com/elliottech/poseidon_crypto v0.0.11 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
|
||||
github.com/ethereum/go-verkle v0.2.2 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gagliardetto/binary v0.8.0 // indirect
|
||||
github.com/gagliardetto/solana-go v1.14.0 // indirect
|
||||
github.com/gagliardetto/treeout v0.1.4 // indirect
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.4 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/holiman/uint256 v1.3.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/jpillora/backoff v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.16.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mailru/easyjson v0.9.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sonirico/vago v0.10.0 // indirect
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd // indirect
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect
|
||||
github.com/sonirico/vago v0.11.4 // indirect
|
||||
github.com/sonirico/vago/lol v0.1.0 // indirect
|
||||
github.com/supranational/blst v0.3.16 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.7 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 // indirect
|
||||
go.elastic.co/apm/v2 v2.7.1 // indirect
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.2 // indirect
|
||||
go.elastic.co/apm/v2 v2.7.2 // indirect
|
||||
go.elastic.co/fastjson v1.5.1 // indirect
|
||||
go.mongodb.org/mongo-driver v1.12.2 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
go.uber.org/ratelimit v0.2.0 // indirect
|
||||
go.uber.org/zap v1.21.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/term v0.35.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
howett.net/plist v1.0.1 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
247
go.sum
247
go.sum
@@ -1,34 +1,19 @@
|
||||
filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU=
|
||||
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
|
||||
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
|
||||
github.com/adshao/go-binance/v2 v2.8.7 h1:n7jkhwIHMdtd/9ZU2gTqFV15XVSbUCjyFlOUAtTd8uU=
|
||||
github.com/adshao/go-binance/v2 v2.8.7/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM=
|
||||
github.com/adshao/go-binance/v2 v2.8.9 h1:NX+4u/LgEmrjTS7OMWU+9ZgfHKFM61RPhnr9/SqWPhc=
|
||||
github.com/adshao/go-binance/v2 v2.8.9/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM=
|
||||
github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo=
|
||||
github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 h1:MzBOUgng9orim59UnfUTLRjMpd09C5uEVQ6RPGeCaVI=
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129/go.mod h1:rFgpPQZYZ8vdbc+48xibu8ALc3yeyd64IhHS+PU6Yyg=
|
||||
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
|
||||
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
|
||||
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
||||
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
|
||||
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
|
||||
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
|
||||
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE=
|
||||
github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 h1:41FLQtKmxWEdyjdgrAm9lZFdS0Ax2XsDxkd/fuztsyQ=
|
||||
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6/go.mod h1:P22TFRynmYRrquJCPalKxZgIIIc9+PkC4kQPeejitsI=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
@@ -37,15 +22,12 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/consensys/gnark-crypto v0.19.0 h1:zXCqeY2txSaMl6G5wFpZzMWJU9HPNh8qxPnYJ1BL9vA=
|
||||
github.com/consensys/gnark-crypto v0.19.0/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
|
||||
github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80=
|
||||
github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg=
|
||||
github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg=
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM=
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc=
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -65,26 +47,14 @@ github.com/elliottech/poseidon_crypto v0.0.11 h1:iX4rCg0m1XIX/7mhXVUEYUJIdQD57zN
|
||||
github.com/elliottech/poseidon_crypto v0.0.11/go.mod h1:NhWxSjPGr5JXRuB2Aepl/+ZrbmUG3hvku/GarB1JR8c=
|
||||
github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
|
||||
github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs=
|
||||
github.com/ethereum/go-ethereum v1.16.5 h1:GZI995PZkzP7ySCxEFaOPzS8+bd8NldE//1qvQDQpe0=
|
||||
github.com/ethereum/go-ethereum v1.16.5/go.mod h1:kId9vOtlYg3PZk9VwKbGlQmSACB5ESPTBGT+M9zjmok=
|
||||
github.com/ethereum/go-ethereum v1.16.7 h1:qeM4TvbrWK0UC0tgkZ7NiRsmBGwsjqc64BHo20U59UQ=
|
||||
github.com/ethereum/go-ethereum v1.16.7/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk=
|
||||
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
|
||||
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
|
||||
github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q=
|
||||
github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A=
|
||||
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
|
||||
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg=
|
||||
github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c=
|
||||
github.com/gagliardetto/solana-go v1.14.0 h1:3WfAi70jOOjAJ0deFMjdhFYlLXATF4tOQXsDNWJtOLw=
|
||||
github.com/gagliardetto/solana-go v1.14.0/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k=
|
||||
github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw=
|
||||
github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok=
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3 h1:JQ2+s1pG4bL+JeLQyGy9c7YLr7hxRI8g7vkAuQYl75k=
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3/go.mod h1:racCcjrdyOUbRDO5eCUGUiyDPrF/ZmwBj/bupPZTVLY=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -110,14 +80,12 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -136,8 +104,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
@@ -154,18 +122,10 @@ github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
|
||||
github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
@@ -176,10 +136,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
@@ -187,50 +145,34 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
|
||||
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
|
||||
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
|
||||
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk=
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
@@ -238,32 +180,24 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/sonirico/go-hyperliquid v0.17.0 h1:eXYACWupwu41O1VtKw17dqe9oOLQ1A2nRElGhg5Ox+4=
|
||||
github.com/sonirico/go-hyperliquid v0.17.0/go.mod h1:sH51Vsu+tPUwc95TL2MoQ8YXSewLWBEJirgzo7sZx6w=
|
||||
github.com/sonirico/go-hyperliquid v0.26.0 h1:C2KjaD2R/AxH1FOPl6W1LyvAx/XUHdTQYgjb4PUcPN0=
|
||||
github.com/sonirico/go-hyperliquid v0.26.0/go.mod h1:SYzazq5hqC8lI1+MgSO0aJVrf0TAfyibp5NjUqnwv2I=
|
||||
github.com/sonirico/vago v0.9.0 h1:DF2OWW2Aaf1xPZmnFv79kBrHmjKX3mVvMbP08vERlKo=
|
||||
github.com/sonirico/vago v0.9.0/go.mod h1:fZxV1RzMe2eaZokbbDvuyoOzG3YapzqRQoOiD9VyJH0=
|
||||
github.com/sonirico/vago v0.10.0 h1:y+4Wo56tK+88a5lUwVrZUO2RRLaPcBgjI5cupKpT1Oc=
|
||||
github.com/sonirico/vago v0.10.0/go.mod h1:HCfnyPHId7V+zBZ5BLfIsdHIO+ewo6+uhF1N0hxlldc=
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd h1:rbvNORW8/0AtH/8W/SUwUykbuh2SeQBrNgFLqYpGTWY=
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd/go.mod h1:pteYccB32seEf19i0TPk7DKdEZdWJ/n9K9DF8AFeXGU=
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 h1:RN5mrigyirb8anBEtdjtHFIufXdacyTi6i4KBfeNXeo=
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU=
|
||||
github.com/sonirico/go-hyperliquid v0.36.0 h1:97aNCFf7PbvXtpCh+kdpqlAmLCyu4vB/USaKL73mSB8=
|
||||
github.com/sonirico/go-hyperliquid v0.36.0/go.mod h1:6UkIfvbqOPtCNcdC2TQ7vVPy5k8pqgoMucbGep4gCuY=
|
||||
github.com/sonirico/vago v0.11.4 h1:KlKh5iYYxKii1bhReKDIE10LPz/GPPqAcn4EvZl4t54=
|
||||
github.com/sonirico/vago v0.11.4/go.mod h1:HCfnyPHId7V+zBZ5BLfIsdHIO+ewo6+uhF1N0hxlldc=
|
||||
github.com/sonirico/vago/lol v0.1.0 h1:YjI+JAQ6enMYlpoM23w6J+1b11TJ8rqPpuD2NDHdFlA=
|
||||
github.com/sonirico/vago/lol v0.1.0/go.mod h1:k8CVrcWhKbPSX5821lt8L64z/DaST2TUaxiJOdPaSA0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@@ -272,7 +206,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
|
||||
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
|
||||
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
@@ -281,128 +214,62 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
|
||||
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 h1:C9+KrlqS8F4SZFu+ct0Jmv2YLmzDhWsI8htK6exd3vg=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1/go.mod h1:wXViB7paxMUrERgZrmUb+0FCqgb13Dull1JOOd8Hcj0=
|
||||
go.elastic.co/apm/v2 v2.7.1 h1:OFjARuESjBsxw7wHrEAnfSVNCHGBATXSI/kPvBARY/A=
|
||||
go.elastic.co/apm/v2 v2.7.1/go.mod h1:tQhBAjwh93b2leuAdzGwta/sP7Yc7QoKTSjeIHHDuog=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.2 h1:JPgmhFEUDfjvIrfZdWEgkwu5H2Nzhze6GFan+qoUQYo=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.2/go.mod h1:oQIxTgTMMef1FgFghymN+GCXpWhW6rpQRihV8Gjoi+w=
|
||||
go.elastic.co/apm/v2 v2.7.2 h1:0blxpxOMOcpBTz034RBqvEw806y0CDJwo/ut+2wZsHA=
|
||||
go.elastic.co/apm/v2 v2.7.2/go.mod h1:KJcwwsaouDzcLd8EviAO+y8yrfZzD6PhUCEg82bvLV4=
|
||||
go.elastic.co/fastjson v1.5.1 h1:zeh1xHrFH79aQ6Xsw7YxixvnOdAl3OSv0xch/jRDzko=
|
||||
go.elastic.co/fastjson v1.5.1/go.mod h1:WtvH5wz8z9pDOPqNYSYKoLLv/9zCWZLeejHWuvdL/EM=
|
||||
go.mongodb.org/mongo-driver v1.12.2 h1:gbWY1bJkkmUB9jjZzcdhOL8O85N9H+Vvsf2yFN0RDws=
|
||||
go.mongodb.org/mongo-driver v1.12.2/go.mod h1:/rGBTebI3XYboVmgz+Wv3Bcbl3aD0QF9zl6kDDw18rQ=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/ratelimit v0.2.0 h1:UQE2Bgi7p2B85uP5dC2bbRtig0C+OeNRnNEafLjsLPA=
|
||||
go.uber.org/ratelimit v0.2.0/go.mod h1:YYBV4e4naJvhpitQrWJu1vCpgB7CboMe0qhltKt6mUg=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
|
||||
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.5 h1:I0hpTIvD5rII+8LgYGrHMA2d4SQPoL6u7ZvJakWKsiA=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.5/go.mod h1:dRos81TkW9C1WJt6tTaE+uV2Lo8qJT3AG2b35+CB/nQ=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.6/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
|
||||
642
kernel/engine.go
642
kernel/engine.go
@@ -6,14 +6,17 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/provider/hyperliquid"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/provider/vergex"
|
||||
"nofx/security"
|
||||
"nofx/store"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -103,6 +106,7 @@ type Context struct {
|
||||
MultiTFMarket map[string]map[string]*market.Data `json:"-"`
|
||||
OITopDataMap map[string]*OITopData `json:"-"`
|
||||
QuantDataMap map[string]*QuantData `json:"-"`
|
||||
VergexDataMap map[string]*vergex.MarketAnalysis `json:"-"`
|
||||
OIRankingData *nofxos.OIRankingData `json:"-"` // Market-wide OI ranking data
|
||||
NetFlowRankingData *nofxos.NetFlowRankingData `json:"-"` // Market-wide fund flow ranking data
|
||||
PriceRankingData *nofxos.PriceRankingData `json:"-"` // Market-wide price gainers/losers
|
||||
@@ -184,6 +188,8 @@ type OIDeltaData struct {
|
||||
type StrategyEngine struct {
|
||||
config *store.StrategyConfig
|
||||
nofxosClient *nofxos.Client
|
||||
vergexClient *vergex.Client
|
||||
vergexRankingCache map[string]*vergex.SignalRankItem
|
||||
}
|
||||
|
||||
// NewStrategyEngine creates strategy execution engine.
|
||||
@@ -216,14 +222,44 @@ func NewStrategyEngine(config *store.StrategyConfig, claw402WalletKey ...string)
|
||||
} else {
|
||||
logger.Warnf("⚠️ Failed to init claw402 data client: %v (using direct nofxos.ai)", err)
|
||||
}
|
||||
|
||||
vergexClient, err := vergex.NewClient(claw402URL, walletKey, &logger.MCPLogger{})
|
||||
if err == nil {
|
||||
logger.Infof("🔗 Vergex signals routed through claw402 (%s)", claw402URL)
|
||||
} else {
|
||||
logger.Warnf("⚠️ Failed to init Vergex claw402 client: %v", err)
|
||||
}
|
||||
return &StrategyEngine{
|
||||
config: config,
|
||||
nofxosClient: client,
|
||||
vergexClient: vergexClient,
|
||||
vergexRankingCache: make(map[string]*vergex.SignalRankItem),
|
||||
}
|
||||
}
|
||||
|
||||
return &StrategyEngine{
|
||||
config: config,
|
||||
nofxosClient: client,
|
||||
vergexRankingCache: make(map[string]*vergex.SignalRankItem),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) usesHyperliquidNativeUniverse() bool {
|
||||
if e == nil || e.config == nil {
|
||||
return false
|
||||
}
|
||||
source := e.config.CoinSource
|
||||
if source.SourceType == "hyper_all" || source.SourceType == "hyper_main" || source.SourceType == "hyper_rank" || source.SourceType == "vergex_signal" || source.UseHyperAll || source.UseHyperMain {
|
||||
return true
|
||||
}
|
||||
for _, symbol := range source.StaticCoins {
|
||||
if market.IsXyzDexAsset(symbol) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetRiskControlConfig gets risk control configuration
|
||||
func (e *StrategyEngine) GetRiskControlConfig() store.RiskControlConfig {
|
||||
return e.config.RiskControl
|
||||
@@ -368,6 +404,27 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
}
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "hyper_rank":
|
||||
coins, err := e.getHyperRankCoins(coinSource.HyperRankCategory, coinSource.HyperRankDirection, coinSource.HyperRankLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "vergex_signal":
|
||||
coins, err := e.getVergexSignalCoins(
|
||||
coinSource.VergexLimit,
|
||||
coinSource.VergexMarketType,
|
||||
coinSource.VergexChain,
|
||||
coinSource.VergexLiqBand,
|
||||
coinSource.HyperRankCategory,
|
||||
coinSource.StaticCoins,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "mixed":
|
||||
if coinSource.UseAI500 {
|
||||
poolCoins, err := e.getAI500Coins(coinSource.AI500Limit)
|
||||
@@ -586,6 +643,297 @@ func (e *StrategyEngine) getHyperMainCoins(limit int) ([]CandidateCoin, error) {
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func clampHyperRankLimit(limit int) int {
|
||||
if limit <= 0 {
|
||||
return 5
|
||||
}
|
||||
if limit > 10 {
|
||||
return 10
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) getHyperRankCoins(category, direction string, limit int) ([]CandidateCoin, error) {
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
if category == "" {
|
||||
category = "stock"
|
||||
}
|
||||
direction = strings.ToLower(strings.TrimSpace(direction))
|
||||
if direction == "" {
|
||||
direction = "gainers"
|
||||
}
|
||||
limit = clampHyperRankLimit(limit)
|
||||
|
||||
ctx := context.Background()
|
||||
var ranked []struct {
|
||||
symbol string
|
||||
info hyperliquid.CoinInfo
|
||||
cat string
|
||||
}
|
||||
|
||||
if category == "crypto" || category == "all" {
|
||||
coins, err := hyperliquid.GetPerpDexCoins(ctx, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Hyperliquid crypto ranking: %w", err)
|
||||
}
|
||||
for _, coin := range coins {
|
||||
ranked = append(ranked, struct {
|
||||
symbol string
|
||||
info hyperliquid.CoinInfo
|
||||
cat string
|
||||
}{symbol: market.Normalize(coin.Symbol + "USDT"), info: coin, cat: "crypto"})
|
||||
}
|
||||
}
|
||||
|
||||
if category != "crypto" {
|
||||
coins, err := hyperliquid.GetPerpDexCoins(ctx, "xyz")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Hyperliquid XYZ ranking: %w", err)
|
||||
}
|
||||
for _, coin := range coins {
|
||||
base := strings.TrimPrefix(coin.Symbol, "xyz:")
|
||||
cat := hyperliquid.XYZCategory(base)
|
||||
if category != "all" && cat != category {
|
||||
continue
|
||||
}
|
||||
ranked = append(ranked, struct {
|
||||
symbol string
|
||||
info hyperliquid.CoinInfo
|
||||
cat string
|
||||
}{symbol: hyperliquid.FormatCoinForAPI("xyz:" + base), info: coin, cat: cat})
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
switch direction {
|
||||
case "losers":
|
||||
return ranked[i].info.Change24hPct < ranked[j].info.Change24hPct
|
||||
case "volume":
|
||||
return ranked[i].info.Volume24h > ranked[j].info.Volume24h
|
||||
default:
|
||||
return ranked[i].info.Change24hPct > ranked[j].info.Change24hPct
|
||||
}
|
||||
})
|
||||
|
||||
if len(ranked) > limit {
|
||||
ranked = ranked[:limit]
|
||||
}
|
||||
candidates := make([]CandidateCoin, 0, len(ranked))
|
||||
source := fmt.Sprintf("hyper_rank_%s_%s", category, direction)
|
||||
for _, item := range ranked {
|
||||
candidates = append(candidates, CandidateCoin{Symbol: item.symbol, Sources: []string{source}})
|
||||
}
|
||||
logger.Infof("✅ Loaded %d Hyperliquid rank coins (%s/%s, capped at %d)", len(candidates), category, direction, limit)
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) getVergexSignalCoins(limit int, marketType, chain, liqBand, category string, selectedSymbols []string) ([]CandidateCoin, error) {
|
||||
if e.vergexClient == nil {
|
||||
return nil, fmt.Errorf("vergex signal source requires a configured claw402 wallet")
|
||||
}
|
||||
if marketType == "" {
|
||||
marketType = vergex.DefaultMarketType
|
||||
}
|
||||
chain = vergex.QueryChain(chain)
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
if limit > store.MaxCandidateCoins {
|
||||
limit = store.MaxCandidateCoins
|
||||
}
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
|
||||
ranking, err := e.vergexClient.GetSignalRanking(context.Background(), vergex.Query{
|
||||
Chain: chain,
|
||||
LiqBand: liqBand,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Vergex signal ranking: %w", err)
|
||||
}
|
||||
|
||||
rankedItems := vergex.FilterSignalRankingItems(ranking.Items, marketType, store.MaxCandidateCoins)
|
||||
if len(rankedItems) == 0 && strings.TrimSpace(chain) != "" {
|
||||
fallbackRanking, fallbackErr := e.vergexClient.GetSignalRanking(context.Background(), vergex.Query{
|
||||
LiqBand: liqBand,
|
||||
})
|
||||
if fallbackErr == nil {
|
||||
fallbackItems := vergex.FilterSignalRankingItems(fallbackRanking.Items, marketType, store.MaxCandidateCoins)
|
||||
if len(fallbackItems) > 0 {
|
||||
logger.Infof("✅ Vergex signal ranking returned TradeFi items after retrying without chain filter (chain=%s)", chain)
|
||||
ranking = fallbackRanking
|
||||
rankedItems = fallbackItems
|
||||
}
|
||||
} else {
|
||||
logger.Warnf("⚠️ Vergex signal ranking retry without chain failed: %v", fallbackErr)
|
||||
}
|
||||
}
|
||||
e.vergexRankingCache = make(map[string]*vergex.SignalRankItem, len(rankedItems))
|
||||
for _, item := range rankedItems {
|
||||
itemCopy := item
|
||||
if symbol := vergex.TradableSymbolForMarket(item.MarketType, item.Symbol); symbol != "" {
|
||||
e.vergexRankingCache[symbol] = &itemCopy
|
||||
}
|
||||
}
|
||||
|
||||
if len(selectedSymbols) > 0 {
|
||||
candidates := make([]CandidateCoin, 0, minInt(len(selectedSymbols), limit))
|
||||
seen := make(map[string]bool)
|
||||
for _, raw := range selectedSymbols {
|
||||
symbol := vergex.TradableSymbolForMarket(marketType, raw)
|
||||
if symbol == "" || seen[symbol] {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"vergex_signal"},
|
||||
})
|
||||
seen[symbol] = true
|
||||
if len(candidates) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, fmt.Errorf("selected Claw402 symbols are not tradable %s items", marketType)
|
||||
}
|
||||
logger.Infof("✅ Loaded %d selected Vergex candidates (%s)", len(candidates), marketType)
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// Direction-balanced selection: interleave the top bullish and top bearish
|
||||
// signals so the candidate universe carries BOTH long and short ideas every
|
||||
// cycle (instead of filling up with whichever bias ranks highest). This is
|
||||
// what lets the AI actually judge — and trade — both directions.
|
||||
var bullItems, bearItems, otherItems []vergex.SignalRankItem
|
||||
for _, item := range rankedItems {
|
||||
if category != "" && category != "all" && item.Category != category {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(item.Bias)) {
|
||||
case "bearish", "short", "sell":
|
||||
bearItems = append(bearItems, item)
|
||||
case "bullish", "long", "buy":
|
||||
bullItems = append(bullItems, item)
|
||||
default:
|
||||
otherItems = append(otherItems, item)
|
||||
}
|
||||
}
|
||||
items := make([]vergex.SignalRankItem, 0, limit)
|
||||
bi, ri, oi := 0, 0, 0
|
||||
for len(items) < limit {
|
||||
progressed := false
|
||||
if bi < len(bullItems) {
|
||||
items = append(items, bullItems[bi])
|
||||
bi++
|
||||
progressed = true
|
||||
if len(items) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if ri < len(bearItems) {
|
||||
items = append(items, bearItems[ri])
|
||||
ri++
|
||||
progressed = true
|
||||
if len(items) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !progressed {
|
||||
if oi < len(otherItems) {
|
||||
items = append(items, otherItems[oi])
|
||||
oi++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
if category != "" && category != "all" {
|
||||
return nil, fmt.Errorf("vergex signal ranking returned no tradable %s items in category %s", marketType, category)
|
||||
}
|
||||
return nil, fmt.Errorf("vergex signal ranking returned no tradable %s items", marketType)
|
||||
}
|
||||
|
||||
candidates := make([]CandidateCoin, 0, len(items))
|
||||
for _, item := range items {
|
||||
itemCopy := item
|
||||
symbol := vergex.TradableSymbolForMarket(item.MarketType, item.Symbol)
|
||||
if symbol == "" {
|
||||
continue
|
||||
}
|
||||
e.vergexRankingCache[symbol] = &itemCopy
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"vergex_signal"},
|
||||
})
|
||||
}
|
||||
logger.Infof("✅ Loaded %d Vergex signal candidates (%s/%s, capped at %d)", len(candidates), marketType, withDefaultText(category, "all"), limit)
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// DirectionalCandidate is a Vergex board candidate with its directional
|
||||
// signal strength (the board z-score; sign follows the bias direction).
|
||||
type DirectionalCandidate struct {
|
||||
Symbol string
|
||||
Score float64
|
||||
}
|
||||
|
||||
// DirectionalCandidates returns bullish (long) and bearish (short) candidates
|
||||
// from the most recent Vergex signal ranking, each ordered by upstream rank
|
||||
// (strongest first) and carrying the signal score so callers can require a
|
||||
// minimum strength. Only populated for vergex_signal coin sources, since that
|
||||
// is the only source carrying a per-symbol directional bias.
|
||||
func (e *StrategyEngine) DirectionalCandidates() (bullish []DirectionalCandidate, bearish []DirectionalCandidate) {
|
||||
if e == nil || len(e.vergexRankingCache) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
type ranked struct {
|
||||
cand DirectionalCandidate
|
||||
rank int
|
||||
}
|
||||
rankKey := func(r int) int {
|
||||
if r > 0 {
|
||||
return r
|
||||
}
|
||||
return 1 << 30
|
||||
}
|
||||
var bl, br []ranked
|
||||
for sym, item := range e.vergexRankingCache {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
entry := ranked{DirectionalCandidate{Symbol: sym, Score: item.Score}, item.Rank}
|
||||
switch strings.ToLower(strings.TrimSpace(item.Bias)) {
|
||||
case "bearish", "short", "sell":
|
||||
br = append(br, entry)
|
||||
case "bullish", "long", "buy":
|
||||
bl = append(bl, entry)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(bl, func(i, j int) bool { return rankKey(bl[i].rank) < rankKey(bl[j].rank) })
|
||||
sort.SliceStable(br, func(i, j int) bool { return rankKey(br[i].rank) < rankKey(br[j].rank) })
|
||||
for _, r := range bl {
|
||||
bullish = append(bullish, r.cand)
|
||||
}
|
||||
for _, r := range br {
|
||||
bearish = append(bearish, r.cand)
|
||||
}
|
||||
return bullish, bearish
|
||||
}
|
||||
|
||||
func withDefaultText(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// External & Quant Data
|
||||
// ============================================================================
|
||||
@@ -677,6 +1025,10 @@ func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) {
|
||||
if !e.config.Indicators.EnableQuantData {
|
||||
return nil, nil
|
||||
}
|
||||
if e.usesHyperliquidNativeUniverse() || market.IsXyzDexAsset(symbol) {
|
||||
logger.Infof("⏭️ Skipping NofxOS quant data for Hyperliquid symbol %s; using native Hyperliquid klines/mark data only", symbol)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Use nofxos client with unified API key
|
||||
include := "oi,price"
|
||||
@@ -767,12 +1119,292 @@ func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*Quant
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) FetchVergexDataBatch(ctx context.Context, symbols []string) map[string]*vergex.MarketAnalysis {
|
||||
result := make(map[string]*vergex.MarketAnalysis)
|
||||
if e == nil || e.config == nil || e.config.CoinSource.SourceType != "vergex_signal" {
|
||||
return result
|
||||
}
|
||||
if e.vergexClient == nil {
|
||||
logger.Warnf("⚠️ Vergex signal data skipped: claw402 wallet is not configured")
|
||||
return result
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
source := e.config.CoinSource
|
||||
marketType := source.VergexMarketType
|
||||
if marketType == "" {
|
||||
marketType = vergex.DefaultMarketType
|
||||
}
|
||||
chain := source.VergexChain
|
||||
chain = vergex.QueryChain(chain)
|
||||
|
||||
seen := make(map[string]bool)
|
||||
limited := make([]string, 0, store.MaxCandidateCoins)
|
||||
for _, symbol := range symbols {
|
||||
symbol = vergexDetailSymbolForLookup(marketType, symbol)
|
||||
if symbol == "" {
|
||||
continue
|
||||
}
|
||||
if seen[symbol] {
|
||||
continue
|
||||
}
|
||||
seen[symbol] = true
|
||||
limited = append(limited, symbol)
|
||||
if len(limited) >= store.MaxCandidateCoins+store.MaxPositions {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
type vergexAnalysisResult struct {
|
||||
symbol string
|
||||
analysis *vergex.MarketAnalysis
|
||||
}
|
||||
|
||||
resultCh := make(chan vergexAnalysisResult, len(limited))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, vergexDetailSymbolConcurrency)
|
||||
for _, symbol := range limited {
|
||||
symbol := symbol
|
||||
querySymbol := vergex.QuerySymbol(symbol)
|
||||
if querySymbol == "" {
|
||||
continue
|
||||
}
|
||||
itemMarketType := marketType
|
||||
itemCategory := ""
|
||||
var ranking *vergex.SignalRankItem
|
||||
if cached, ok := e.vergexRankingCache[symbol]; ok && cached != nil {
|
||||
ranking = cached
|
||||
if cached.MarketType != "" {
|
||||
itemMarketType = cached.MarketType
|
||||
}
|
||||
itemCategory = cached.Category
|
||||
}
|
||||
|
||||
analysis := &vergex.MarketAnalysis{
|
||||
Symbol: symbol,
|
||||
QuerySymbol: querySymbol,
|
||||
MarketType: itemMarketType,
|
||||
Ranking: ranking,
|
||||
}
|
||||
query := vergex.Query{
|
||||
MarketType: itemMarketType,
|
||||
Symbol: symbol,
|
||||
Chain: chain,
|
||||
LiqBand: source.VergexLiqBand,
|
||||
Category: itemCategory,
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
analysis.SignalLabError = ctx.Err().Error()
|
||||
analysis.HeatmapError = ctx.Err().Error()
|
||||
resultCh <- vergexAnalysisResult{symbol: symbol, analysis: analysis}
|
||||
return
|
||||
}
|
||||
e.populateVergexDetailData(ctx, analysis, query)
|
||||
if len(analysis.SignalLab) > 0 || len(analysis.Heatmap) > 0 ||
|
||||
analysis.SignalLabError != "" || analysis.HeatmapError != "" || analysis.Ranking != nil {
|
||||
resultCh <- vergexAnalysisResult{symbol: symbol, analysis: analysis}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(resultCh)
|
||||
for item := range resultCh {
|
||||
result[item.symbol] = item.analysis
|
||||
}
|
||||
|
||||
logger.Infof("📊 Vergex detail data ready for %d symbols", len(result))
|
||||
return result
|
||||
}
|
||||
|
||||
func vergexDetailSymbolForLookup(marketType, symbol string) string {
|
||||
return vergex.TradableSymbolForMarket(marketType, symbol)
|
||||
}
|
||||
|
||||
const (
|
||||
vergexDetailRequestTimeout = 45 * time.Second
|
||||
vergexDetailSymbolConcurrency = 2
|
||||
)
|
||||
|
||||
func (e *StrategyEngine) populateVergexDetailData(ctx context.Context, analysis *vergex.MarketAnalysis, query vergex.Query) {
|
||||
type endpointResult struct {
|
||||
name string
|
||||
body json.RawMessage
|
||||
err error
|
||||
}
|
||||
|
||||
run := func(name string, fetch func(context.Context, vergex.Query) (json.RawMessage, error), out chan<- endpointResult) {
|
||||
requestCtx, cancel := context.WithTimeout(ctx, vergexDetailRequestTimeout)
|
||||
defer cancel()
|
||||
body, err := fetch(requestCtx, query)
|
||||
out <- endpointResult{name: name, body: body, err: err}
|
||||
}
|
||||
|
||||
out := make(chan endpointResult, 2)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
run("signal-lab", e.fetchVergexSignalLabWithFallback, out)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
run("heatmap", e.fetchVergexHeatmapWithFallback, out)
|
||||
}()
|
||||
wg.Wait()
|
||||
close(out)
|
||||
|
||||
for item := range out {
|
||||
switch item.name {
|
||||
case "signal-lab":
|
||||
if item.err != nil {
|
||||
logger.Warnf("⚠️ Failed to fetch Vergex signal-lab for %s: %v", analysis.Symbol, item.err)
|
||||
analysis.SignalLabError = item.err.Error()
|
||||
} else {
|
||||
analysis.SignalLab = item.body
|
||||
}
|
||||
case "heatmap":
|
||||
if item.err != nil {
|
||||
logger.Warnf("⚠️ Failed to fetch Vergex heatmap for %s: %v", analysis.Symbol, item.err)
|
||||
analysis.HeatmapError = item.err.Error()
|
||||
} else {
|
||||
analysis.Heatmap = item.body
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) fetchVergexSignalLabWithFallback(ctx context.Context, query vergex.Query) (json.RawMessage, error) {
|
||||
var lastErr error
|
||||
for idx, candidate := range vergexDetailQueryCandidates(query) {
|
||||
body, err := e.vergexClient.GetSignalLab(ctx, candidate)
|
||||
if err == nil {
|
||||
if idx > 0 {
|
||||
logger.Infof("✅ Vergex signal-lab succeeded with fallback marketType=%s chain=%s", candidate.MarketType, withDefaultText(candidate.Chain, "default"))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !isRetryableVergexDetailError(err) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) fetchVergexHeatmapWithFallback(ctx context.Context, query vergex.Query) (json.RawMessage, error) {
|
||||
var lastErr error
|
||||
for idx, candidate := range vergexDetailQueryCandidates(query) {
|
||||
body, err := e.vergexClient.GetCostLiquidationHeatmap(ctx, candidate)
|
||||
if err == nil {
|
||||
if idx > 0 {
|
||||
logger.Infof("✅ Vergex heatmap succeeded with fallback marketType=%s chain=%s", candidate.MarketType, withDefaultText(candidate.Chain, "default"))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !isRetryableVergexDetailError(err) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func vergexDetailQueryCandidates(query vergex.Query) []vergex.Query {
|
||||
marketTypes := vergexDetailMarketTypeCandidates(query)
|
||||
chains := uniqueValues(query.Chain, "mainnet", "")
|
||||
|
||||
candidates := make([]vergex.Query, 0, len(marketTypes)*len(chains))
|
||||
for _, marketType := range marketTypes {
|
||||
for _, chain := range chains {
|
||||
candidate := query
|
||||
candidate.MarketType = marketType
|
||||
candidate.Chain = chain
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func vergexDetailMarketTypeCandidates(query vergex.Query) []string {
|
||||
if isVergexAllMarketType(query.MarketType) {
|
||||
if market.IsXyzDexAsset(query.Symbol) {
|
||||
return uniqueNonEmpty(vergex.DefaultMarketType, "hip3-perp", "hip3Perp", "core_perp")
|
||||
}
|
||||
return uniqueNonEmpty("core_perp", vergex.DefaultMarketType, "hip3-perp", "hip3Perp")
|
||||
}
|
||||
values := []string{query.MarketType, vergex.DefaultMarketType, "hip3-perp", "hip3Perp", "core_perp"}
|
||||
return uniqueNonEmpty(values...)
|
||||
}
|
||||
|
||||
func isVergexAllMarketType(marketType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(marketType)) {
|
||||
case "", "all", "any", "ranking", "signal-ranking", "signal_ranking", "claw402", "vergex":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isRetryableVergexDetailError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "invalid markettype") ||
|
||||
strings.Contains(msg, "invalid_request") ||
|
||||
strings.Contains(msg, "invalid chain") ||
|
||||
strings.Contains(msg, "market not found") ||
|
||||
strings.Contains(msg, "not_found")
|
||||
}
|
||||
|
||||
func uniqueNonEmpty(values ...string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func uniqueValues(values ...string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FetchOIRankingData fetches market-wide OI ranking data
|
||||
func (e *StrategyEngine) FetchOIRankingData() *nofxos.OIRankingData {
|
||||
indicators := e.config.Indicators
|
||||
if !indicators.EnableOIRanking {
|
||||
return nil
|
||||
}
|
||||
if e.usesHyperliquidNativeUniverse() {
|
||||
logger.Infof("⏭️ Skipping NofxOS OI ranking for Hyperliquid strategy; native Hyperliquid universe is the source of truth")
|
||||
return nil
|
||||
}
|
||||
|
||||
duration := indicators.OIRankingDuration
|
||||
if duration == "" {
|
||||
@@ -804,6 +1436,10 @@ func (e *StrategyEngine) FetchNetFlowRankingData() *nofxos.NetFlowRankingData {
|
||||
if !indicators.EnableNetFlowRanking {
|
||||
return nil
|
||||
}
|
||||
if e.usesHyperliquidNativeUniverse() {
|
||||
logger.Infof("⏭️ Skipping NofxOS netflow ranking for Hyperliquid strategy; native Hyperliquid universe is the source of truth")
|
||||
return nil
|
||||
}
|
||||
|
||||
duration := indicators.NetFlowRankingDuration
|
||||
if duration == "" {
|
||||
@@ -836,6 +1472,10 @@ func (e *StrategyEngine) FetchPriceRankingData() *nofxos.PriceRankingData {
|
||||
if !indicators.EnablePriceRanking {
|
||||
return nil
|
||||
}
|
||||
if e.usesHyperliquidNativeUniverse() {
|
||||
logger.Infof("⏭️ Skipping NofxOS price ranking for Hyperliquid strategy; native Hyperliquid universe is the source of truth")
|
||||
return nil
|
||||
}
|
||||
|
||||
durations := indicators.PriceRankingDuration
|
||||
if durations == "" {
|
||||
|
||||
@@ -84,6 +84,8 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
|
||||
return nil, fmt.Errorf("failed to fetch market data: %w", err)
|
||||
}
|
||||
}
|
||||
pruneCandidateCoinsWithoutMarketData(ctx)
|
||||
enrichVergexDataWithStrategy(ctx, engine)
|
||||
|
||||
// Ensure OITopDataMap is initialized
|
||||
if ctx.OITopDataMap == nil {
|
||||
@@ -141,6 +143,30 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func enrichVergexDataWithStrategy(ctx *Context, engine *StrategyEngine) {
|
||||
if ctx == nil || engine == nil || ctx.VergexDataMap != nil {
|
||||
return
|
||||
}
|
||||
if engine.GetConfig().CoinSource.SourceType != "vergex_signal" {
|
||||
return
|
||||
}
|
||||
symbolSet := make(map[string]bool)
|
||||
symbols := make([]string, 0, len(ctx.CandidateCoins)+len(ctx.Positions))
|
||||
for _, coin := range ctx.CandidateCoins {
|
||||
if !symbolSet[coin.Symbol] {
|
||||
symbolSet[coin.Symbol] = true
|
||||
symbols = append(symbols, coin.Symbol)
|
||||
}
|
||||
}
|
||||
for _, pos := range ctx.Positions {
|
||||
if !symbolSet[pos.Symbol] {
|
||||
symbolSet[pos.Symbol] = true
|
||||
symbols = append(symbols, pos.Symbol)
|
||||
}
|
||||
}
|
||||
ctx.VergexDataMap = engine.FetchVergexDataBatch(nil, symbols)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Market Data Fetching
|
||||
// ============================================================================
|
||||
@@ -223,6 +249,21 @@ func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func pruneCandidateCoinsWithoutMarketData(ctx *Context) {
|
||||
if ctx == nil || len(ctx.CandidateCoins) == 0 || len(ctx.MarketDataMap) == 0 {
|
||||
return
|
||||
}
|
||||
kept := make([]CandidateCoin, 0, len(ctx.CandidateCoins))
|
||||
for _, coin := range ctx.CandidateCoins {
|
||||
if _, ok := ctx.MarketDataMap[coin.Symbol]; ok {
|
||||
kept = append(kept, coin)
|
||||
continue
|
||||
}
|
||||
logger.Infof("⚠️ Skipping candidate %s in AI prompt: no valid market/K-line data", coin.Symbol)
|
||||
}
|
||||
ctx.CandidateCoins = kept
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI Response Parsing
|
||||
// ============================================================================
|
||||
|
||||
42
kernel/engine_directional_test.go
Normal file
42
kernel/engine_directional_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"nofx/provider/vergex"
|
||||
)
|
||||
|
||||
func TestDirectionalCandidates(t *testing.T) {
|
||||
e := &StrategyEngine{
|
||||
vergexRankingCache: map[string]*vergex.SignalRankItem{
|
||||
"xyz:NVDA": {Symbol: "xyz:NVDA", Bias: "bullish", Rank: 2, Score: 1.07},
|
||||
"xyz:AAPL": {Symbol: "xyz:AAPL", Bias: "bullish", Rank: 1, Score: 1.76},
|
||||
"BTC": {Symbol: "BTC", Bias: "bearish", Rank: 3, Score: -0.9},
|
||||
"ETH": {Symbol: "ETH", Bias: "bearish", Rank: 1, Score: -0.05},
|
||||
"SOL": {Symbol: "SOL", Bias: "neutral", Rank: 1, Score: 0.4},
|
||||
},
|
||||
}
|
||||
|
||||
bull, bear := e.DirectionalCandidates()
|
||||
|
||||
if len(bull) != 2 || bull[0].Symbol != "xyz:AAPL" || bull[1].Symbol != "xyz:NVDA" {
|
||||
t.Fatalf("bullish should be rank-ordered [xyz:AAPL xyz:NVDA], got %v", bull)
|
||||
}
|
||||
if bull[0].Score != 1.76 || bull[1].Score != 1.07 {
|
||||
t.Fatalf("bullish candidates should carry their board scores, got %v", bull)
|
||||
}
|
||||
if len(bear) != 2 || bear[0].Symbol != "ETH" || bear[1].Symbol != "BTC" {
|
||||
t.Fatalf("bearish should be rank-ordered [ETH BTC], got %v", bear)
|
||||
}
|
||||
if bear[0].Score != -0.05 || bear[1].Score != -0.9 {
|
||||
t.Fatalf("bearish candidates should carry their board scores, got %v", bear)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectionalCandidatesEmpty(t *testing.T) {
|
||||
e := &StrategyEngine{}
|
||||
bull, bear := e.DirectionalCandidates()
|
||||
if len(bull) != 0 || len(bear) != 0 {
|
||||
t.Fatalf("empty cache should yield no candidates, got %v %v", bull, bear)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package kernel
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
@@ -33,10 +34,18 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi
|
||||
}
|
||||
|
||||
if d.Action == "open_long" || d.Action == "open_short" {
|
||||
// Asset tiering for validation:
|
||||
// - BTC/ETH crypto perps use the BTC/ETH tier (typically 5x equity).
|
||||
// - Hyperliquid XYZ assets (US equities, commodities, forex) are
|
||||
// also treated as the higher tier — they are not crypto altcoins
|
||||
// and the user's quick-trade flow shows them at the higher cap,
|
||||
// so the validator must match.
|
||||
// - Everything else is altcoin (1x equity by default).
|
||||
maxLeverage := altcoinLeverage
|
||||
posRatio := altcoinPosRatio
|
||||
maxPositionValue := accountEquity * posRatio
|
||||
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
|
||||
isMajor := d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" || market.IsXyzDexAsset(d.Symbol)
|
||||
if isMajor {
|
||||
maxLeverage = btcEthLeverage
|
||||
posRatio = btcEthPosRatio
|
||||
maxPositionValue = accountEquity * posRatio
|
||||
@@ -69,9 +78,12 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi
|
||||
|
||||
tolerance := maxPositionValue * 0.01
|
||||
if d.PositionSizeUSD > maxPositionValue+tolerance {
|
||||
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
|
||||
switch {
|
||||
case d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT":
|
||||
return fmt.Errorf("BTC/ETH single coin position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", maxPositionValue, posRatio, d.PositionSizeUSD)
|
||||
} else {
|
||||
case market.IsXyzDexAsset(d.Symbol):
|
||||
return fmt.Errorf("%s position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", d.Symbol, maxPositionValue, posRatio, d.PositionSizeUSD)
|
||||
default:
|
||||
return fmt.Errorf("altcoin single coin position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", maxPositionValue, posRatio, d.PositionSizeUSD)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"nofx/market"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/provider/vergex"
|
||||
"nofx/store"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -18,34 +19,53 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
|
||||
var sb strings.Builder
|
||||
riskControl := e.config.RiskControl
|
||||
promptSections := e.config.PromptSections
|
||||
// System prompts are intentionally English-only. UI copy can be localized,
|
||||
// but the model contract should stay language-stable for an international
|
||||
// open-source project and for reproducible trading behavior.
|
||||
lang := LangEnglish
|
||||
zh := false
|
||||
singleSymbol, primarySymbol := e.singleSymbolInfo()
|
||||
|
||||
// Configs created in the Chinese-UI era carry legacy stored prompt sections
|
||||
// and custom prompts written for a different contract; ignore them wholesale
|
||||
// and fall back to the canonical built-in English sections.
|
||||
legacyZhConfig := strings.EqualFold(strings.TrimSpace(e.config.Language), "zh")
|
||||
if legacyZhConfig {
|
||||
promptSections = store.PromptSectionsConfig{}
|
||||
}
|
||||
|
||||
if e.usesVergexSignalPrompt() {
|
||||
return e.buildVergexSystemPrompt(accountEquity, variant, lang, zh, singleSymbol, primarySymbol)
|
||||
}
|
||||
|
||||
// 0. Data Dictionary & Schema (ensure AI understands all fields)
|
||||
lang := e.GetLanguage()
|
||||
schemaPrompt := GetSchemaPrompt(lang)
|
||||
sb.WriteString(schemaPrompt)
|
||||
sb.WriteString(GetSchemaPrompt(lang))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString("---\n\n")
|
||||
|
||||
// 1. Role definition (editable)
|
||||
if promptSections.RoleDefinition != "" {
|
||||
sb.WriteString(promptSections.RoleDefinition)
|
||||
// 1. Role definition (editable; falls back to a generic intro in the
|
||||
// correct language so we don't mix EN headings with ZH custom text).
|
||||
roleDefinition := englishOnlyPromptSection(promptSections.RoleDefinition)
|
||||
if roleDefinition != "" {
|
||||
sb.WriteString(roleDefinition)
|
||||
sb.WriteString("\n\n")
|
||||
} else if zh {
|
||||
sb.WriteString("# You are a professional Hyperliquid USDC multi-asset trading AI\n\n")
|
||||
sb.WriteString("Your task is to make trading decisions based on the provided market data.\n\n")
|
||||
} else {
|
||||
sb.WriteString("# You are a professional cryptocurrency trading AI\n\n")
|
||||
sb.WriteString("Your task is to make trading decisions based on provided market data.\n\n")
|
||||
sb.WriteString("# You are a professional Hyperliquid USDC multi-asset trading AI\n\n")
|
||||
sb.WriteString("Your task is to make trading decisions based on the provided market data.\n\n")
|
||||
}
|
||||
|
||||
// 2. Trading mode variant
|
||||
switch strings.ToLower(strings.TrimSpace(variant)) {
|
||||
case "aggressive":
|
||||
sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts, can build positions in batches when confidence ≥ 70\n- Allow higher positions, but must strictly set stop-loss and explain risk-reward ratio\n\n")
|
||||
case "conservative":
|
||||
sb.WriteString("## Mode: Conservative\n- Only open positions when multiple signals resonate\n- Prioritize cash preservation, must pause for multiple periods after consecutive losses\n\n")
|
||||
case "scalping":
|
||||
sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n")
|
||||
}
|
||||
writeModeVariant(&sb, variant, zh)
|
||||
|
||||
// 3. Hard constraints (risk control)
|
||||
// 3. Hard constraints (risk control).
|
||||
//
|
||||
// `singleSymbol` is true for strategies that deliberately trade just one
|
||||
// instrument (the quick-create flow, single-asset templates). For those,
|
||||
// the "BTC/ETH vs Altcoin" two-tier categorization is irrelevant and
|
||||
// actively misleading — we surface a single position-value limit instead.
|
||||
btcEthPosValueRatio := riskControl.BTCETHMaxPositionValueRatio
|
||||
if btcEthPosValueRatio <= 0 {
|
||||
btcEthPosValueRatio = 5.0
|
||||
@@ -55,168 +75,678 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
|
||||
altcoinPosValueRatio = 1.0
|
||||
}
|
||||
|
||||
sb.WriteString("# Hard Constraints (Risk Control)\n\n")
|
||||
sb.WriteString("## CODE ENFORCED (Backend validation, cannot be bypassed):\n")
|
||||
sb.WriteString(fmt.Sprintf("- Max Positions: %d coins simultaneously\n", riskControl.MaxPositions))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (Altcoins): max %.0f USDT (= equity %.0f × %.1fx)\n",
|
||||
accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (BTC/ETH): max %.0f USDT (= equity %.0f × %.1fx)\n",
|
||||
accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Max Margin Usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min Position Size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
|
||||
sb.WriteString("## AI GUIDED (Recommended, you should follow):\n")
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage: Altcoins max %dx | BTC/ETH max %dx\n",
|
||||
riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
|
||||
sb.WriteString(fmt.Sprintf("- Risk-Reward Ratio: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min Confidence: ≥%d to open position\n\n", riskControl.MinConfidence))
|
||||
|
||||
// Position sizing guidance
|
||||
sb.WriteString("## Position Sizing Guidance\n")
|
||||
sb.WriteString("Calculate `position_size_usd` based on your confidence and the Position Value Limits above:\n")
|
||||
sb.WriteString("- High confidence (≥85): Use 80-100%% of max position value limit\n")
|
||||
sb.WriteString("- Medium confidence (70-84): Use 50-80%% of max position value limit\n")
|
||||
sb.WriteString("- Low confidence (60-69): Use 30-50%% of max position value limit\n")
|
||||
sb.WriteString(fmt.Sprintf("- Example: With equity %.0f and BTC/ETH ratio %.1fx, max is %.0f USDT\n",
|
||||
accountEquity, btcEthPosValueRatio, accountEquity*btcEthPosValueRatio))
|
||||
sb.WriteString("- **DO NOT** just use available_balance as position_size_usd. Use the Position Value Limits!\n\n")
|
||||
writeHardConstraints(&sb, accountEquity, riskControl, btcEthPosValueRatio, altcoinPosValueRatio, singleSymbol, primarySymbol, zh)
|
||||
|
||||
// 4. Trading frequency (editable)
|
||||
if promptSections.TradingFrequency != "" {
|
||||
sb.WriteString(promptSections.TradingFrequency)
|
||||
tradingFrequency := englishOnlyPromptSection(promptSections.TradingFrequency)
|
||||
if tradingFrequency != "" {
|
||||
sb.WriteString(tradingFrequency)
|
||||
sb.WriteString("\n\n")
|
||||
} else if zh {
|
||||
sb.WriteString("# ⏱️ Trading Frequency Awareness\n\n")
|
||||
sb.WriteString("- Excellent traders: 2-4 trades/day ≈ 0.1-0.2 trades/hour\n")
|
||||
sb.WriteString("- >2 trades/hour = overtrading\n")
|
||||
sb.WriteString("- Single position hold time ≥ 45-90 minutes\n")
|
||||
sb.WriteString("If you find yourself trading every cycle → standards too low; if closing positions < 45 minutes → too impulsive.\n\n")
|
||||
} else {
|
||||
sb.WriteString("# ⏱️ Trading Frequency Awareness\n\n")
|
||||
sb.WriteString("- Excellent traders: 2-4 trades/day ≈ 0.1-0.2 trades/hour\n")
|
||||
sb.WriteString("- >2 trades/hour = Overtrading\n")
|
||||
sb.WriteString("- Single position hold time ≥ 30-60 minutes\n")
|
||||
sb.WriteString("If you find yourself trading every period → standards too low; if closing positions < 30 minutes → too impatient.\n\n")
|
||||
sb.WriteString("- >2 trades/hour = overtrading\n")
|
||||
sb.WriteString("- Single position hold time ≥ 45-90 minutes\n")
|
||||
sb.WriteString("If you find yourself trading every cycle → standards too low; if closing positions < 45 minutes → too impulsive.\n\n")
|
||||
}
|
||||
|
||||
// 5. Entry standards (editable)
|
||||
if promptSections.EntryStandards != "" {
|
||||
sb.WriteString(promptSections.EntryStandards)
|
||||
entryStandards := englishOnlyPromptSection(promptSections.EntryStandards)
|
||||
if entryStandards != "" {
|
||||
sb.WriteString(entryStandards)
|
||||
if zh {
|
||||
sb.WriteString("\n\nYou have the following indicator data:\n")
|
||||
e.writeAvailableIndicators(&sb)
|
||||
} else {
|
||||
sb.WriteString("\n\nYou have the following indicator data:\n")
|
||||
}
|
||||
e.writeAvailableIndicators(&sb, zh)
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("\n**Confidence ≥ %d** required to open positions.\n\n", riskControl.MinConfidence))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("\n**Confidence ≥ %d** required to open positions.\n\n", riskControl.MinConfidence))
|
||||
}
|
||||
} else if zh {
|
||||
sb.WriteString("# 🎯 Entry Standards (Strict)\n\n")
|
||||
sb.WriteString("Only open positions when multiple signals resonate. You have:\n")
|
||||
e.writeAvailableIndicators(&sb, zh)
|
||||
sb.WriteString(fmt.Sprintf("\nFeel free to use any effective analysis method, but **confidence ≥ %d** is required to open positions; avoid low-quality behaviors such as single-indicator entries, contradictory signals, sideways chop, or re-entering immediately after a close.\n\n", riskControl.MinConfidence))
|
||||
} else {
|
||||
sb.WriteString("# 🎯 Entry Standards (Strict)\n\n")
|
||||
sb.WriteString("Only open positions when multiple signals resonate. You have:\n")
|
||||
e.writeAvailableIndicators(&sb)
|
||||
sb.WriteString(fmt.Sprintf("\nFeel free to use any effective analysis method, but **confidence ≥ %d** required to open positions; avoid low-quality behaviors such as single indicators, contradictory signals, sideways consolidation, reopening immediately after closing, etc.\n\n", riskControl.MinConfidence))
|
||||
e.writeAvailableIndicators(&sb, zh)
|
||||
sb.WriteString(fmt.Sprintf("\nFeel free to use any effective analysis method, but **confidence ≥ %d** is required to open positions; avoid low-quality behaviors such as single-indicator entries, contradictory signals, sideways chop, or re-entering immediately after a close.\n\n", riskControl.MinConfidence))
|
||||
}
|
||||
|
||||
// 6. Decision process (editable)
|
||||
if promptSections.DecisionProcess != "" {
|
||||
sb.WriteString(promptSections.DecisionProcess)
|
||||
decisionProcess := englishOnlyPromptSection(promptSections.DecisionProcess)
|
||||
if decisionProcess != "" {
|
||||
sb.WriteString(decisionProcess)
|
||||
sb.WriteString("\n\n")
|
||||
} else if zh {
|
||||
sb.WriteString("# 📋 Decision Process\n\n")
|
||||
sb.WriteString("1. Check positions → take profit / stop loss?\n")
|
||||
sb.WriteString("2. Scan candidates + multi-timeframe → are there strong signals?\n")
|
||||
sb.WriteString("3. Write chain of thought first, then output structured JSON\n\n")
|
||||
} else {
|
||||
sb.WriteString("# 📋 Decision Process\n\n")
|
||||
sb.WriteString("1. Check positions → Should we take profit/stop-loss\n")
|
||||
sb.WriteString("2. Scan candidate coins + multi-timeframe → Are there strong signals\n")
|
||||
sb.WriteString("1. Check positions → take profit / stop loss?\n")
|
||||
sb.WriteString("2. Scan candidates + multi-timeframe → are there strong signals?\n")
|
||||
sb.WriteString("3. Write chain of thought first, then output structured JSON\n\n")
|
||||
}
|
||||
|
||||
// 7. Output format
|
||||
sb.WriteString("# Output Format (Strictly Follow)\n\n")
|
||||
sb.WriteString("**Must use XML tags <reasoning> and <decision> to separate chain of thought and decision JSON, avoiding parsing errors**\n\n")
|
||||
sb.WriteString("## Format Requirements\n\n")
|
||||
sb.WriteString("<reasoning>\n")
|
||||
sb.WriteString("Your chain of thought analysis...\n")
|
||||
sb.WriteString("- Briefly analyze your thinking process \n")
|
||||
sb.WriteString("</reasoning>\n\n")
|
||||
sb.WriteString("<decision>\n")
|
||||
sb.WriteString("Step 2: JSON decision array\n\n")
|
||||
sb.WriteString("```json\n[\n")
|
||||
// Use the actual configured position value ratio for BTC/ETH in the example
|
||||
examplePositionSize := accountEquity * btcEthPosValueRatio
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n",
|
||||
riskControl.BTCETHMaxLeverage, examplePositionSize))
|
||||
sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n")
|
||||
sb.WriteString("]\n```\n")
|
||||
sb.WriteString("</decision>\n\n")
|
||||
sb.WriteString("## Field Description\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (opening recommended ≥ %d)\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- **IMPORTANT**: All numeric values must be calculated numbers, NOT formulas/expressions (e.g., use `27.76` not `3000 * 0.01`)\n\n")
|
||||
// 7. Output format — schema spec stays in English (this is a parser
|
||||
// contract; reasoning copy is localized below).
|
||||
writeOutputFormat(&sb, accountEquity, btcEthPosValueRatio, riskControl, singleSymbol, primarySymbol, zh)
|
||||
|
||||
// 8. Custom Prompt
|
||||
if e.config.CustomPrompt != "" {
|
||||
// 8. Custom Prompt.
|
||||
//
|
||||
// For single-symbol Hyperliquid XYZ assets (US equities, commodities,
|
||||
// forex), we replace any stored CustomPrompt with a built-in English
|
||||
// stock-trader template. This serves two purposes:
|
||||
// 1. The auto-generated CustomPrompt from the quick-create flow used
|
||||
// to be Chinese (matching UI language), which produced an
|
||||
// incoherent mixed-language final prompt that confused the LLM.
|
||||
// 2. It guarantees a stock-specific, US-equity-tuned briefing
|
||||
// regardless of when the strategy was first created.
|
||||
customPrompt := englishOnlyPromptSection(e.config.CustomPrompt)
|
||||
if legacyZhConfig {
|
||||
customPrompt = ""
|
||||
}
|
||||
if singleSymbol && market.IsXyzDexAsset(primarySymbol) {
|
||||
customPrompt = buildXYZStockCustomPrompt(primarySymbol)
|
||||
}
|
||||
|
||||
if customPrompt != "" {
|
||||
if zh {
|
||||
sb.WriteString("# 📌 Personalized Trading Strategy\n\n")
|
||||
sb.WriteString(e.config.CustomPrompt)
|
||||
} else {
|
||||
sb.WriteString("# 📌 Personalized Trading Strategy\n\n")
|
||||
}
|
||||
sb.WriteString(customPrompt)
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString("Note: The above personalized strategy is a supplement to the basic rules and cannot violate the basic risk control principles.\n")
|
||||
if zh {
|
||||
sb.WriteString("Note: the above personalized strategy supplements the basic rules and may not violate the core risk controls.\n")
|
||||
} else {
|
||||
sb.WriteString("Note: the above personalized strategy supplements the basic rules and may not violate the core risk controls.\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) {
|
||||
func (e *StrategyEngine) usesVergexSignalPrompt() bool {
|
||||
if e == nil || e.config == nil {
|
||||
return false
|
||||
}
|
||||
coinSource := e.config.CoinSource
|
||||
sourceType := strings.ToLower(strings.TrimSpace(coinSource.SourceType))
|
||||
return sourceType == "vergex_signal" ||
|
||||
sourceType == "claw402" ||
|
||||
sourceType == "claw402_vergex" ||
|
||||
coinSource.VergexMarketType != "" ||
|
||||
coinSource.VergexChain != "" ||
|
||||
coinSource.VergexLimit > 0
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant string, lang Language, zh bool, singleSymbol bool, primarySymbol string) string {
|
||||
var sb strings.Builder
|
||||
riskControl := e.config.RiskControl
|
||||
|
||||
writeVergexSchemaPrompt(&sb, zh)
|
||||
sb.WriteString("\n\n---\n\n")
|
||||
|
||||
if zh {
|
||||
sb.WriteString("# You are the NOFX Claw402 auto-trader\n\n")
|
||||
sb.WriteString("Trade only Hyperliquid instruments returned by this cycle's Claw402.ai/Vergex board. You may trade only the current candidate symbols and existing positions; never invent tickers or rotate outside the provided universe.\n\n")
|
||||
sb.WriteString("# Decision Data Priority\n\n")
|
||||
sb.WriteString("1. Claw402.ai Signal Ranking: candidate pool, rank, direction and category.\n")
|
||||
sb.WriteString("2. Claw402.ai Signal Lab: trend, momentum, event/model confirmation; this is the core pre-entry confirmation source.\n")
|
||||
sb.WriteString("3. Claw402.ai Cost/Liquidation Heatmap: crowded liquidation/cost zones, stop placement and target zones.\n")
|
||||
sb.WriteString("4. Raw OHLCV candles: entry timing, trend structure, volatility and risk/reward validation.\n\n")
|
||||
sb.WriteString("# Trading Rules\n\n")
|
||||
sb.WriteString("- Manage existing positions before opening new ones.\n")
|
||||
sb.WriteString("- Open only when Signal Lab, heatmap and raw candles broadly agree; wait when key data is missing or contradictory.\n")
|
||||
sb.WriteString("- Ranking alone is not an entry reason; it only defines the candidate pool.\n")
|
||||
sb.WriteString("- Every symbol in Candidate Coins is part of the allowed trading universe; missing detail can lower confidence or trigger waiting, but does not make the symbol non-tradable.\n")
|
||||
sb.WriteString("- If Signal Lab or heatmap is absent from that symbol's Vergex Claw402 Signals, state it in reasoning; if it is present, never claim the symbol lacks that data.\n")
|
||||
sb.WriteString("- Avoid churn: unless stopping out or taking a strong profit, hold new positions for at least 60 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("- Fees are the main edge killer: a round trip costs roughly 0.1%% of notional (about 1%% of margin at 10x). Only take setups whose expected move to target is at least 3x that cost; fewer, higher-conviction, longer-hold trades beat frequent scalps.\n")
|
||||
sb.WriteString("- Stops must sit beyond invalidation; targets should prefer heatmap resistance/liquidation zones or valid risk/reward levels.\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 60 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("- Fees are the main edge killer: a round trip costs roughly 0.1%% of notional (about 1%% of margin at 10x). Only take setups whose expected move to target is at least 3x that cost; fewer, higher-conviction, longer-hold trades beat frequent scalps.\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 := vergexCustomPromptSection(e.config.CustomPrompt)
|
||||
if customPrompt != "" {
|
||||
sb.WriteString("# User Preference\n\n")
|
||||
sb.WriteString(customPrompt)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// vergexCustomPromptSection returns the user's custom prompt for the vergex
|
||||
// path, dropping legacy directional overrides ("long only" era) that would
|
||||
// contradict the data-driven direction rule baked into this prompt.
|
||||
func vergexCustomPromptSection(section string) string {
|
||||
trimmed := englishOnlyPromptSection(section)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
legacyDirectives := []string{
|
||||
"long only",
|
||||
"long-only",
|
||||
"do not short",
|
||||
"no shorts",
|
||||
"must open a long",
|
||||
"short only",
|
||||
"short-only",
|
||||
}
|
||||
for _, directive := range legacyDirectives {
|
||||
if strings.Contains(lower, directive) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func englishOnlyPromptSection(section string) string {
|
||||
trimmed := strings.TrimSpace(section)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if detectLanguage(trimmed) == LangChinese {
|
||||
return ""
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func writeVergexSchemaPrompt(sb *strings.Builder, zh bool) {
|
||||
if zh {
|
||||
sb.WriteString("# Claw402.ai TradeFi Data Guide\n\n")
|
||||
sb.WriteString("- Equity: total account value including unrealized PnL, in USDT.\n")
|
||||
sb.WriteString("- Balance: available balance for new positions, in USDT.\n")
|
||||
sb.WriteString("- Margin: current margin usage; higher means more risk.\n")
|
||||
sb.WriteString("- Position: current holdings with side, entry, leverage, unrealized PnL and liquidation price.\n")
|
||||
sb.WriteString("- Claw402 Ranking: tradable candidate pool, rank, direction and category for this cycle.\n")
|
||||
sb.WriteString("- Signal Lab: per-symbol Claw402 deep signal used to confirm trend and quality.\n")
|
||||
sb.WriteString("- Cost/Liquidation Heatmap: cost and liquidation clusters used for stops, targets and crowding risk.\n")
|
||||
sb.WriteString("- Raw OHLCV Kline: raw candles used for trend structure, entry timing and risk/reward.\n")
|
||||
} else {
|
||||
sb.WriteString("# Claw402.ai TradeFi Data Guide\n\n")
|
||||
sb.WriteString("- Equity: total account value including unrealized PnL, in USDT.\n")
|
||||
sb.WriteString("- Balance: available balance for new positions, in USDT.\n")
|
||||
sb.WriteString("- Margin: current margin usage; higher means more risk.\n")
|
||||
sb.WriteString("- Position: current holdings with side, entry, leverage, unrealized PnL and liquidation price.\n")
|
||||
sb.WriteString("- Claw402 Ranking: tradable candidate pool, rank, direction and category for this cycle.\n")
|
||||
sb.WriteString("- Signal Lab: per-symbol Claw402 deep signal used to confirm trend and quality.\n")
|
||||
sb.WriteString("- Cost/Liquidation Heatmap: cost and liquidation clusters used for stops, targets and crowding risk.\n")
|
||||
sb.WriteString("- Raw OHLCV Kline: raw candles used for trend structure, entry timing and risk/reward.\n")
|
||||
}
|
||||
}
|
||||
|
||||
func writeVergexHardConstraints(sb *strings.Builder, accountEquity float64, riskControl store.RiskControlConfig, tradeFiPositionValueRatio float64, zh bool) {
|
||||
maxPositionValue := accountEquity * tradeFiPositionValueRatio
|
||||
if zh {
|
||||
sb.WriteString("# Hard Risk Constraints\n\n")
|
||||
sb.WriteString("## Backend enforced\n")
|
||||
sb.WriteString(fmt.Sprintf("- Max positions: %d Claw402 candidate instruments at the same time\n", riskControl.MaxPositions))
|
||||
sb.WriteString(fmt.Sprintf("- Max notional per position: %.0f USDT (= equity %.0f × %.1fx)\n", maxPositionValue, accountEquity, tradeFiPositionValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Max margin usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min order size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
sb.WriteString("## AI guided\n")
|
||||
sb.WriteString(fmt.Sprintf("- Leverage: every open position must use exactly %dx\n", riskControl.AltcoinMaxLeverage))
|
||||
sb.WriteString(fmt.Sprintf("- Risk/reward: ≥1:%.1f\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min confidence to open: ≥%d\n\n", riskControl.MinConfidence))
|
||||
sb.WriteString("# Position Sizing\n\n")
|
||||
sb.WriteString("For every `open_long` or `open_short`, use the full max notional per position.\n")
|
||||
sb.WriteString("- Do not scale position_size_usd down by confidence.\n")
|
||||
sb.WriteString("- Do not open small probe positions.\n")
|
||||
sb.WriteString("- If the setup is not strong enough for full size, output `wait`.\n")
|
||||
sb.WriteString("- Do not use available_balance directly as position_size_usd.\n\n")
|
||||
} else {
|
||||
sb.WriteString("# Hard Risk Constraints\n\n")
|
||||
sb.WriteString("## Backend enforced\n")
|
||||
sb.WriteString(fmt.Sprintf("- Max positions: %d Claw402 candidate instruments at the same time\n", riskControl.MaxPositions))
|
||||
sb.WriteString(fmt.Sprintf("- Max notional per position: %.0f USDT (= equity %.0f × %.1fx)\n", maxPositionValue, accountEquity, tradeFiPositionValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Max margin usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min order size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
sb.WriteString("## AI guided\n")
|
||||
sb.WriteString(fmt.Sprintf("- Leverage: every open position must use exactly %dx\n", riskControl.AltcoinMaxLeverage))
|
||||
sb.WriteString(fmt.Sprintf("- Risk/reward: ≥1:%.1f\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min confidence to open: ≥%d\n\n", riskControl.MinConfidence))
|
||||
sb.WriteString("# Position Sizing\n\n")
|
||||
sb.WriteString("For every `open_long` or `open_short`, use the full max notional per position.\n")
|
||||
sb.WriteString("- Do not scale position_size_usd down by confidence.\n")
|
||||
sb.WriteString("- Do not open small probe positions.\n")
|
||||
sb.WriteString("- If the setup is not strong enough for full size, output `wait`.\n")
|
||||
sb.WriteString("- Do not use available_balance directly as position_size_usd.\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
func writeVergexOutputFormat(sb *strings.Builder, accountEquity float64, riskControl store.RiskControlConfig, tradeFiPositionValueRatio float64, singleSymbol bool, primarySymbol string, zh bool) {
|
||||
exampleSymbol := "xyz:NVDA"
|
||||
secondSymbol := "xyz:AAPL"
|
||||
if singleSymbol && strings.TrimSpace(primarySymbol) != "" {
|
||||
exampleSymbol = primarySymbol
|
||||
secondSymbol = primarySymbol
|
||||
}
|
||||
positionSize := accountEquity * tradeFiPositionValueRatio
|
||||
leverage := riskControl.AltcoinMaxLeverage
|
||||
if leverage <= 0 {
|
||||
leverage = 1
|
||||
}
|
||||
|
||||
sb.WriteString("# Output Format (Strictly Follow)\n\n")
|
||||
if zh {
|
||||
sb.WriteString("Use XML tags <reasoning> and <decision> to separate concise analysis from the decision JSON.\n\n")
|
||||
sb.WriteString("Direction must be data-driven: use `open_long` for confirmed upside structures and `open_short` for confirmed downside structures; never default to long-only or short-only behavior.\n\n")
|
||||
if !singleSymbol {
|
||||
sb.WriteString("Evaluate both directions every cycle, but enter a side only when its own signals independently justify it. Never open a position just to balance the book — an unbalanced book beats a forced trade.\n\n")
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("Use XML tags <reasoning> and <decision> to separate concise analysis from the decision JSON.\n\n")
|
||||
sb.WriteString("Direction must be data-driven: use `open_long` for confirmed upside structures and `open_short` for confirmed downside structures; never default to long-only or short-only behavior.\n\n")
|
||||
if !singleSymbol {
|
||||
sb.WriteString("Evaluate both directions every cycle, but enter a side only when its own signals independently justify it. Never open a position just to balance the book — an unbalanced book beats a forced trade.\n\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("<reasoning>\n")
|
||||
if zh {
|
||||
sb.WriteString("Briefly state whether Claw402 ranking, Signal Lab, heatmap and candles agree; if data is missing or conflicting, explain why you wait.\n")
|
||||
} else {
|
||||
sb.WriteString("Briefly state whether Claw402 ranking, Signal Lab, heatmap and candles agree; if data is missing or conflicting, explain why you wait.\n")
|
||||
}
|
||||
sb.WriteString("</reasoning>\n\n")
|
||||
sb.WriteString("<decision>\n")
|
||||
sb.WriteString("```json\n[\n")
|
||||
if singleSymbol {
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0}\n", exampleSymbol, leverage, positionSize))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_long\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0},\n", exampleSymbol, leverage, positionSize))
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0}\n", secondSymbol, leverage, positionSize))
|
||||
}
|
||||
sb.WriteString("]\n```\n")
|
||||
sb.WriteString("</decision>\n\n")
|
||||
|
||||
if zh {
|
||||
sb.WriteString("## Field Requirements\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100; recommended ≥ %d to open\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- All numeric values must be calculated numbers, not formulas.\n")
|
||||
if singleSymbol {
|
||||
sb.WriteString(fmt.Sprintf("- This strategy trades only `%s`; JSON symbol must match it exactly.\n", exampleSymbol))
|
||||
} else {
|
||||
sb.WriteString("- JSON symbols must exactly match current candidates or existing positions; keep `xyz:` on XYZ instruments, and do not add `xyz:` or `USDT` to core crypto symbols.\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
} else {
|
||||
sb.WriteString("## Field Requirements\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100; recommended ≥ %d to open\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- All numeric values must be calculated numbers, not formulas.\n")
|
||||
if singleSymbol {
|
||||
sb.WriteString(fmt.Sprintf("- This strategy trades only `%s`; JSON symbol must match it exactly.\n", exampleSymbol))
|
||||
} else {
|
||||
sb.WriteString("- JSON symbols must exactly match current candidates or existing positions; keep `xyz:` on XYZ instruments, and do not add `xyz:` or `USDT` to core crypto symbols.\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// buildXYZStockCustomPrompt returns the canonical English directional stock
|
||||
// briefing the agent uses for single-symbol Hyperliquid USDC perpetuals on
|
||||
// the XYZ board. Symbol is inlined for LLM grounding so it never confuses the
|
||||
// trading instrument.
|
||||
func buildXYZStockCustomPrompt(symbol string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Trade ONLY the Hyperliquid USDC perpetual %s (US equity / xyz board).\n\n", symbol))
|
||||
sb.WriteString("Core stance: DIRECTIONAL, SIGNAL-DRIVEN. You may open long or short; never force a trade when Signal Lab, liquidation structure and candles disagree.\n\n")
|
||||
|
||||
sb.WriteString("## Flat-Account Rule\n")
|
||||
sb.WriteString("If `Current Positions` is None / empty, evaluate both directions from scratch.\n")
|
||||
sb.WriteString("- Use `open_long` only when upside continuation or bullish reversal is confirmed.\n")
|
||||
sb.WriteString("- Use `open_short` only when downside continuation or bearish reversal is confirmed.\n")
|
||||
sb.WriteString("- Use `wait` when neither side meets the minimum confidence and risk/reward threshold.\n")
|
||||
sb.WriteString("- Do not raise confidence just to force an order; confidence must reflect the evidence.\n\n")
|
||||
|
||||
sb.WriteString("## Long Entry Conditions\n")
|
||||
sb.WriteString("- Break of the prior session/intraday high on rising volume.\n")
|
||||
sb.WriteString("- Pullback to a clearly held intraday support (prior swing low, VWAP, EMA20/50) with a bullish reaction bar.\n")
|
||||
sb.WriteString("- Sector tape strength (broad US-equity bid, sympathy with peers in the same theme).\n")
|
||||
sb.WriteString("- Confirmed catalyst: earnings beat, guide up, sector rotation, macro tailwind.\n\n")
|
||||
|
||||
sb.WriteString("## Short Entry Conditions\n")
|
||||
sb.WriteString("- Breakdown below intraday support or value area with expanding volume.\n")
|
||||
sb.WriteString("- Failed breakout, lower high, or bearish rejection at resistance.\n")
|
||||
sb.WriteString("- Signal Lab / liquidation structure shows downside fuel, trapped longs, or weak support below.\n")
|
||||
sb.WriteString("- Negative catalyst: earnings miss, guide down, sector weakness, macro headwind.\n\n")
|
||||
|
||||
sb.WriteString("## Risk Guardrails (non-negotiable)\n")
|
||||
sb.WriteString("- Per-trade stop-loss: 1.5-3% from entry. ALWAYS set a numeric `stop_loss`.\n")
|
||||
sb.WriteString("- Take-profit: target at least R/R 2:1; set a numeric `take_profit`.\n")
|
||||
sb.WriteString("- Per-trade notional: <= 25% of account equity (probing 10-15%, full 20-25%).\n")
|
||||
sb.WriteString("- Leverage: 2-3x default, never above 5x. Never go all-in.\n")
|
||||
sb.WriteString("- Do not flip directly from long to short or short to long in the same cycle. Manage or close the open position first.\n\n")
|
||||
|
||||
sb.WriteString("## Position Management\n")
|
||||
sb.WriteString("- Trail stop to breakeven once +1R, take partial profits at +2R if momentum stalls.\n")
|
||||
sb.WriteString("- Cut quickly if price breaks the stop or the catalyst thesis fails.\n")
|
||||
sb.WriteString("- Holding past 45 minutes is fine; flipping in/out every cycle is not.\n\n")
|
||||
|
||||
sb.WriteString("## Discipline\n")
|
||||
sb.WriteString(fmt.Sprintf("- Single-symbol mandate: never rotate into another ticker. The decision JSON `symbol` MUST be exactly \"%s\".\n", symbol))
|
||||
sb.WriteString("- Before every decision: check current price vs prior pivot, volume vs 5m/1h average, and the broader US-equity tape.\n")
|
||||
sb.WriteString("- If positions are open, prioritize managing them over piling on new ones.")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// singleSymbolInfo returns (true, "ARM-USDC") for static-coin strategies that
|
||||
// trade exactly one instrument. Multi-symbol strategies return (false, "").
|
||||
// The flag is used to drop crypto-specific "BTC/ETH vs Altcoin" labeling and
|
||||
// to put the actual trading symbol into the JSON example.
|
||||
func (e *StrategyEngine) singleSymbolInfo() (bool, string) {
|
||||
coinSource := e.config.CoinSource
|
||||
if (coinSource.SourceType == "static" || coinSource.SourceType == "vergex_signal") && len(coinSource.StaticCoins) == 1 {
|
||||
return true, strings.ToUpper(strings.TrimSpace(coinSource.StaticCoins[0]))
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func writeModeVariant(sb *strings.Builder, variant string, zh bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(variant)) {
|
||||
case "aggressive":
|
||||
if zh {
|
||||
sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts; may scale in when confidence ≥ 70\n- Allow larger positions, but must strictly set stop-loss and explain the risk-reward ratio\n\n")
|
||||
} else {
|
||||
sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts; may scale in when confidence ≥ 70\n- Allow larger positions, but must strictly set stop-loss and explain the risk-reward ratio\n\n")
|
||||
}
|
||||
case "conservative":
|
||||
if zh {
|
||||
sb.WriteString("## Mode: Conservative\n- Open positions only when multiple signals resonate\n- Prioritize capital preservation; pause for multiple periods after consecutive losses\n\n")
|
||||
} else {
|
||||
sb.WriteString("## Mode: Conservative\n- Open positions only when multiple signals resonate\n- Prioritize capital preservation; pause for multiple periods after consecutive losses\n\n")
|
||||
}
|
||||
case "scalping":
|
||||
if zh {
|
||||
sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n")
|
||||
} else {
|
||||
sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeHardConstraints(sb *strings.Builder, accountEquity float64, riskControl store.RiskControlConfig, btcEthPosValueRatio, altcoinPosValueRatio float64, singleSymbol bool, primarySymbol string, zh bool) {
|
||||
if zh {
|
||||
sb.WriteString("# Hard Constraints (Risk Control)\n\n")
|
||||
sb.WriteString("## CODE ENFORCED (backend validation, cannot be bypassed):\n")
|
||||
sb.WriteString(fmt.Sprintf("- Max Positions: %d instruments simultaneously\n", riskControl.MaxPositions))
|
||||
} else {
|
||||
sb.WriteString("# Hard Constraints (Risk Control)\n\n")
|
||||
sb.WriteString("## CODE ENFORCED (backend validation, cannot be bypassed):\n")
|
||||
sb.WriteString(fmt.Sprintf("- Max Positions: %d instruments simultaneously\n", riskControl.MaxPositions))
|
||||
}
|
||||
|
||||
if singleSymbol {
|
||||
// One symbol — pick the higher of the two configured ratios so the
|
||||
// limit isn't accidentally clamped to the altcoin cap for a stock.
|
||||
ratio := altcoinPosValueRatio
|
||||
if btcEthPosValueRatio > ratio {
|
||||
ratio = btcEthPosValueRatio
|
||||
}
|
||||
maxVal := accountEquity * ratio
|
||||
symLabel := primarySymbol
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (%s): max %.0f USDT (= equity %.0f × %.1fx)\n", symLabel, maxVal, accountEquity, ratio))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (%s): max %.0f USDT (= equity %.0f × %.1fx)\n", symLabel, maxVal, accountEquity, ratio))
|
||||
}
|
||||
} else {
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (Altcoin/Stock): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (BTC/ETH): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (Altcoin/Stock): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (BTC/ETH): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
|
||||
}
|
||||
}
|
||||
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- Max Margin Usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min Position Size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
sb.WriteString("## AI GUIDED (recommended):\n")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Max Margin Usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min Position Size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
sb.WriteString("## AI GUIDED (recommended):\n")
|
||||
}
|
||||
|
||||
if singleSymbol {
|
||||
lev := riskControl.AltcoinMaxLeverage
|
||||
if riskControl.BTCETHMaxLeverage > lev {
|
||||
lev = riskControl.BTCETHMaxLeverage
|
||||
}
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage (%s): max %dx\n", primarySymbol, lev))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage (%s): max %dx\n", primarySymbol, lev))
|
||||
}
|
||||
} else {
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage: Altcoin/Stock max %dx | BTC/ETH max %dx\n", riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage: Altcoin/Stock max %dx | BTC/ETH max %dx\n", riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
|
||||
}
|
||||
}
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- Risk-Reward Ratio: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min Confidence: ≥%d to open position\n\n", riskControl.MinConfidence))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Risk-Reward Ratio: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min Confidence: ≥%d to open position\n\n", riskControl.MinConfidence))
|
||||
}
|
||||
|
||||
// Position sizing guidance
|
||||
exampleRatio := btcEthPosValueRatio
|
||||
if singleSymbol {
|
||||
exampleRatio = altcoinPosValueRatio
|
||||
if btcEthPosValueRatio > exampleRatio {
|
||||
exampleRatio = btcEthPosValueRatio
|
||||
}
|
||||
}
|
||||
if zh {
|
||||
sb.WriteString("## Position Sizing Guidance\n")
|
||||
sb.WriteString("Calculate `position_size_usd` from your confidence and the Position Value Limits above:\n")
|
||||
sb.WriteString("- High confidence (≥85): use 80-100%% of the position value limit\n")
|
||||
sb.WriteString("- Medium confidence (70-84): use 50-80%% of the position value limit\n")
|
||||
sb.WriteString("- Low confidence (60-69): use 30-50%% of the position value limit\n")
|
||||
sb.WriteString(fmt.Sprintf("- Example: equity %.0f × %.1fx = max %.0f USDT\n", accountEquity, exampleRatio, accountEquity*exampleRatio))
|
||||
sb.WriteString("- **DO NOT** just use available_balance as position_size_usd. Use the Position Value Limit!\n\n")
|
||||
} else {
|
||||
sb.WriteString("## Position Sizing Guidance\n")
|
||||
sb.WriteString("Calculate `position_size_usd` from your confidence and the Position Value Limits above:\n")
|
||||
sb.WriteString("- High confidence (≥85): use 80-100%% of the position value limit\n")
|
||||
sb.WriteString("- Medium confidence (70-84): use 50-80%% of the position value limit\n")
|
||||
sb.WriteString("- Low confidence (60-69): use 30-50%% of the position value limit\n")
|
||||
sb.WriteString(fmt.Sprintf("- Example: equity %.0f × %.1fx = max %.0f USDT\n", accountEquity, exampleRatio, accountEquity*exampleRatio))
|
||||
sb.WriteString("- **DO NOT** just use available_balance as position_size_usd. Use the Position Value Limit!\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
func writeOutputFormat(sb *strings.Builder, accountEquity, btcEthPosValueRatio float64, riskControl store.RiskControlConfig, singleSymbol bool, primarySymbol string, zh bool) {
|
||||
// Output format schema MUST stay English/structural; parser depends on it.
|
||||
sb.WriteString("# Output Format (Strictly Follow)\n\n")
|
||||
if zh {
|
||||
sb.WriteString("**Must use XML tags <reasoning> and <decision> to separate chain of thought and decision JSON, avoiding parsing errors**\n\n")
|
||||
} else {
|
||||
sb.WriteString("**Must use XML tags <reasoning> and <decision> to separate chain of thought and decision JSON, avoiding parsing errors**\n\n")
|
||||
}
|
||||
sb.WriteString("## Format Requirements\n\n")
|
||||
sb.WriteString("<reasoning>\n")
|
||||
if zh {
|
||||
sb.WriteString("Your chain of thought analysis...\n- Briefly analyze your thinking process\n")
|
||||
} else {
|
||||
sb.WriteString("Your chain of thought analysis...\n- Briefly analyze your thinking process\n")
|
||||
}
|
||||
sb.WriteString("</reasoning>\n\n")
|
||||
sb.WriteString("<decision>\n")
|
||||
if zh {
|
||||
sb.WriteString("Step 2: JSON decision array\n\n")
|
||||
} else {
|
||||
sb.WriteString("Step 2: JSON decision array\n\n")
|
||||
}
|
||||
sb.WriteString("```json\n[\n")
|
||||
|
||||
// Build a JSON example using the actual trading symbol when the strategy
|
||||
// is single-symbol. Falls back to the legacy BTC/ETH two-line example
|
||||
// only for multi-symbol strategies that genuinely have BTC/ETH on tap.
|
||||
if singleSymbol {
|
||||
lev := riskControl.AltcoinMaxLeverage
|
||||
if riskControl.BTCETHMaxLeverage > lev {
|
||||
lev = riskControl.BTCETHMaxLeverage
|
||||
}
|
||||
ratio := btcEthPosValueRatio // already chosen as the larger above when single-symbol
|
||||
size := accountEquity * ratio
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_long\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0},\n", primarySymbol, lev, size))
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"wait\"}\n", primarySymbol))
|
||||
} else {
|
||||
examplePositionSize := accountEquity * btcEthPosValueRatio
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n",
|
||||
riskControl.BTCETHMaxLeverage, examplePositionSize))
|
||||
sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n")
|
||||
}
|
||||
sb.WriteString("]\n```\n")
|
||||
sb.WriteString("</decision>\n\n")
|
||||
|
||||
if zh {
|
||||
sb.WriteString("## Field Description\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (opening recommended ≥ %d)\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- **IMPORTANT**: all numeric values must be calculated numbers, NOT formulas/expressions (e.g. use `27.76`, not `3000 * 0.01`)\n")
|
||||
if singleSymbol {
|
||||
sb.WriteString(fmt.Sprintf("- **This strategy trades only %s.** The JSON `symbol` MUST match `%s` exactly — do not write `%s` variants that drop the suffix or add USDT.\n", primarySymbol, primarySymbol, primarySymbol))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
} else {
|
||||
sb.WriteString("## Field Description\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (opening recommended ≥ %d)\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- **IMPORTANT**: all numeric values must be calculated numbers, NOT formulas/expressions (e.g. use `27.76`, not `3000 * 0.01`)\n")
|
||||
if singleSymbol {
|
||||
sb.WriteString(fmt.Sprintf("- **This strategy trades only %s.** The JSON `symbol` MUST match `%s` exactly — do not add USDT/USDC suffix variants.\n", primarySymbol, primarySymbol))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder, zh bool) {
|
||||
indicators := e.config.Indicators
|
||||
kline := indicators.Klines
|
||||
|
||||
label := func(en, zhStr string) string {
|
||||
if zh {
|
||||
return zhStr
|
||||
}
|
||||
return en
|
||||
}
|
||||
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- %s price series", kline.PrimaryTimeframe))
|
||||
if kline.EnableMultiTimeframe {
|
||||
sb.WriteString(fmt.Sprintf(" + %s K-line series\n", kline.LongerTimeframe))
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- %s price series", kline.PrimaryTimeframe))
|
||||
if kline.EnableMultiTimeframe {
|
||||
sb.WriteString(fmt.Sprintf(" + %s K-line series\n", kline.LongerTimeframe))
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if indicators.EnableEMA {
|
||||
sb.WriteString("- EMA indicators")
|
||||
sb.WriteString("- " + label("EMA indicators", "EMA indicators"))
|
||||
if len(indicators.EMAPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.EMAPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "periods"), indicators.EMAPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableMACD {
|
||||
sb.WriteString("- MACD indicators\n")
|
||||
sb.WriteString("- " + label("MACD indicators", "MACD indicators") + "\n")
|
||||
}
|
||||
|
||||
if indicators.EnableRSI {
|
||||
sb.WriteString("- RSI indicators")
|
||||
sb.WriteString("- " + label("RSI indicators", "RSI indicators"))
|
||||
if len(indicators.RSIPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.RSIPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "periods"), indicators.RSIPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableATR {
|
||||
sb.WriteString("- ATR indicators")
|
||||
sb.WriteString("- " + label("ATR indicators", "ATR indicators"))
|
||||
if len(indicators.ATRPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.ATRPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "periods"), indicators.ATRPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableBOLL {
|
||||
sb.WriteString("- Bollinger Bands (BOLL) - Upper/Middle/Lower bands")
|
||||
sb.WriteString("- " + label("Bollinger Bands (BOLL) - Upper/Middle/Lower bands", "Bollinger Bands (BOLL) - Upper/Middle/Lower bands"))
|
||||
if len(indicators.BOLLPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.BOLLPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "periods"), indicators.BOLLPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableVolume {
|
||||
sb.WriteString("- Volume data\n")
|
||||
sb.WriteString("- " + label("Volume data", "Volume data") + "\n")
|
||||
}
|
||||
|
||||
if indicators.EnableOI {
|
||||
sb.WriteString("- Open Interest (OI) data\n")
|
||||
sb.WriteString("- " + label("Open Interest (OI) data", "Open Interest (OI) data") + "\n")
|
||||
}
|
||||
|
||||
if indicators.EnableFundingRate {
|
||||
sb.WriteString("- Funding rate\n")
|
||||
sb.WriteString("- " + label("Funding rate", "Funding rate") + "\n")
|
||||
}
|
||||
|
||||
if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseAI500 || e.config.CoinSource.UseOITop {
|
||||
sb.WriteString("- AI500 / OI_Top filter tags (if available)\n")
|
||||
sb.WriteString("- " + label("AI500 / OI_Top filter tags (if available)", "AI500 / OI_Top filter tags (if available)") + "\n")
|
||||
}
|
||||
|
||||
if indicators.EnableQuantData {
|
||||
sb.WriteString("- Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)\n")
|
||||
sb.WriteString("- " + label("Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)", "Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)") + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,13 +807,13 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
}
|
||||
|
||||
if lang == LangChinese {
|
||||
sb.WriteString("## 历史交易统计\n")
|
||||
sb.WriteString(fmt.Sprintf("总交易: %d 笔 | 盈利因子: %.2f | 夏普比率: %.2f | 盈亏比: %.2f\n",
|
||||
sb.WriteString("## Historical Trading Statistics\n")
|
||||
sb.WriteString(fmt.Sprintf("Total Trades: %d | Profit Factor: %.2f | Sharpe: %.2f | Win/Loss Ratio: %.2f\n",
|
||||
ctx.TradingStats.TotalTrades,
|
||||
ctx.TradingStats.ProfitFactor,
|
||||
ctx.TradingStats.SharpeRatio,
|
||||
winLossRatio))
|
||||
sb.WriteString(fmt.Sprintf("总盈亏: %+.2f USDT | 平均盈利: +%.2f | 平均亏损: -%.2f | 最大回撤: %.1f%%\n",
|
||||
sb.WriteString(fmt.Sprintf("Total PnL: %+.2f USDT | Avg Win: +%.2f | Avg Loss: -%.2f | Max Drawdown: %.1f%%\n",
|
||||
ctx.TradingStats.TotalPnL,
|
||||
ctx.TradingStats.AvgWin,
|
||||
ctx.TradingStats.AvgLoss,
|
||||
@@ -291,13 +821,13 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
|
||||
// Performance hints based on profit factor, sharpe, and drawdown
|
||||
if ctx.TradingStats.ProfitFactor >= 1.5 && ctx.TradingStats.SharpeRatio >= 1 {
|
||||
sb.WriteString("表现: 良好 - 保持当前策略\n")
|
||||
sb.WriteString("Performance: GOOD - maintain current strategy\n")
|
||||
} else if ctx.TradingStats.ProfitFactor < 1 {
|
||||
sb.WriteString("表现: 需改进 - 提高盈亏比,优化止盈止损\n")
|
||||
sb.WriteString("Performance: NEEDS IMPROVEMENT - improve win/loss ratio, optimize TP/SL\n")
|
||||
} else if ctx.TradingStats.MaxDrawdownPct > 30 {
|
||||
sb.WriteString("表现: 风险偏高 - 减少仓位,控制回撤\n")
|
||||
sb.WriteString("Performance: HIGH RISK - reduce position size, control drawdown\n")
|
||||
} else {
|
||||
sb.WriteString("表现: 正常 - 有优化空间\n")
|
||||
sb.WriteString("Performance: NORMAL - room for optimization\n")
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("## Historical Trading Statistics\n")
|
||||
@@ -368,6 +898,11 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
sb.WriteString(e.formatQuantData(quantData))
|
||||
}
|
||||
}
|
||||
if ctx.VergexDataMap != nil {
|
||||
if vergexData, hasVergex := ctx.VergexDataMap[coin.Symbol]; hasVergex {
|
||||
sb.WriteString(e.formatVergexData(vergexData))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
@@ -394,7 +929,7 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
}
|
||||
|
||||
sb.WriteString("---\n\n")
|
||||
sb.WriteString("Now please analyze and output your decision (Chain of Thought + JSON)\n")
|
||||
sb.WriteString("Now please analyze briefly and output the decision JSON.\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -433,6 +968,11 @@ func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Co
|
||||
sb.WriteString(e.formatQuantData(quantData))
|
||||
}
|
||||
}
|
||||
if ctx.VergexDataMap != nil {
|
||||
if vergexData, hasVergex := ctx.VergexDataMap[pos.Symbol]; hasVergex {
|
||||
sb.WriteString(e.formatVergexData(vergexData))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
@@ -491,11 +1031,26 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
||||
return " (Hyperliquid All)"
|
||||
case "hyper_main":
|
||||
return " (Hyperliquid Top20)"
|
||||
case "vergex_signal":
|
||||
return " (Vergex Signal)"
|
||||
}
|
||||
if strings.HasPrefix(sources[0], "hyper_rank") {
|
||||
return " (Hyperliquid Dynamic Rank)"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) formatVergexData(data *vergex.MarketAnalysis) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("\nVergex Claw402 Signals:\n")
|
||||
sb.WriteString(vergex.FormatAnalysisForAI(data))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Market Data Formatting
|
||||
// ============================================================================
|
||||
|
||||
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 = "# You are a professional Hyperliquid USDC multi-asset trading AI"
|
||||
cfg.CustomPrompt = "Long only, no shorts."
|
||||
|
||||
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 multi-asset trading AI",
|
||||
"Long only",
|
||||
"Altcoin",
|
||||
"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 = "# You are a Chinese system prompt"
|
||||
cfg.PromptSections.TradingFrequency = "# High-frequency trading\nTrade every minute."
|
||||
cfg.PromptSections.EntryStandards = "# Entry\nOpen positions freely."
|
||||
cfg.PromptSections.DecisionProcess = "# Decision\nOutput directly."
|
||||
cfg.CustomPrompt = "Chinese preference should not enter the system prompt."
|
||||
|
||||
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
|
||||
}
|
||||
@@ -105,7 +105,7 @@ func formatContextData(ctx *Context, lang Language) string {
|
||||
|
||||
// formatHeaderZH formats header information (Chinese)
|
||||
func formatHeaderZH(ctx *Context) string {
|
||||
return fmt.Sprintf("# 📊 交易决策请求\n\n时间: %s | 周期: #%d | 运行时长: %d 分钟\n\n",
|
||||
return fmt.Sprintf("# 📊 Trading Decision Request\n\nTime: %s | Cycle: #%d | Runtime: %d minutes\n\n",
|
||||
ctx.CurrentTime, ctx.CallCount, ctx.RuntimeMinutes)
|
||||
}
|
||||
|
||||
@@ -114,18 +114,18 @@ func formatAccountZH(ctx *Context) string {
|
||||
acc := ctx.Account
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## 账户状态\n\n")
|
||||
sb.WriteString(fmt.Sprintf("总权益: %.2f USDT | ", acc.TotalEquity))
|
||||
sb.WriteString(fmt.Sprintf("可用余额: %.2f USDT (%.1f%%) | ", acc.AvailableBalance, (acc.AvailableBalance/acc.TotalEquity)*100))
|
||||
sb.WriteString(fmt.Sprintf("总盈亏: %+.2f%% | ", acc.TotalPnLPct))
|
||||
sb.WriteString(fmt.Sprintf("保证金使用率: %.1f%% | ", acc.MarginUsedPct))
|
||||
sb.WriteString(fmt.Sprintf("持仓数: %d\n\n", acc.PositionCount))
|
||||
sb.WriteString("## Account Status\n\n")
|
||||
sb.WriteString(fmt.Sprintf("Total Equity: %.2f USDT | ", acc.TotalEquity))
|
||||
sb.WriteString(fmt.Sprintf("Available Balance: %.2f USDT (%.1f%%) | ", acc.AvailableBalance, (acc.AvailableBalance/acc.TotalEquity)*100))
|
||||
sb.WriteString(fmt.Sprintf("Total PnL: %+.2f%% | ", acc.TotalPnLPct))
|
||||
sb.WriteString(fmt.Sprintf("Margin Usage: %.1f%% | ", acc.MarginUsedPct))
|
||||
sb.WriteString(fmt.Sprintf("Position Count: %d\n\n", acc.PositionCount))
|
||||
|
||||
// Add risk warnings
|
||||
if acc.MarginUsedPct > 70 {
|
||||
sb.WriteString("⚠️ **风险警告**: 保证金使用率 > 70%,处于高风险状态!\n\n")
|
||||
sb.WriteString("⚠️ **Risk Warning**: Margin usage > 70%, in a high-risk state!\n\n")
|
||||
} else if acc.MarginUsedPct > 50 {
|
||||
sb.WriteString("⚠️ **风险提示**: 保证金使用率 > 50%,建议谨慎开仓\n\n")
|
||||
sb.WriteString("⚠️ **Risk Notice**: Margin usage > 50%, open positions with caution\n\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
@@ -134,7 +134,7 @@ func formatAccountZH(ctx *Context) string {
|
||||
// formatTradingStatsZH formats historical trading statistics (Chinese)
|
||||
func formatTradingStatsZH(stats *TradingStats) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## 历史交易统计\n\n")
|
||||
sb.WriteString("## Historical Trading Statistics\n\n")
|
||||
|
||||
// Win/loss ratio calculation
|
||||
var winLossRatio float64
|
||||
@@ -143,49 +143,49 @@ func formatTradingStatsZH(stats *TradingStats) string {
|
||||
}
|
||||
|
||||
// Metric definitions (focusing on core metrics, excluding win rate)
|
||||
sb.WriteString("**指标说明**:\n")
|
||||
sb.WriteString("- 盈利因子: 总盈利 ÷ 总亏损(>1表示盈利,>1.5为良好,>2为优秀)\n")
|
||||
sb.WriteString("- 夏普比率: (平均收益 - 无风险收益) ÷ 收益标准差(>1良好,>2优秀)\n")
|
||||
sb.WriteString("- 盈亏比: 平均盈利 ÷ 平均亏损(>1.5为良好,>2为优秀)\n")
|
||||
sb.WriteString("- 最大回撤: 资金曲线从峰值到谷底的最大跌幅(<20%为低风险)\n\n")
|
||||
sb.WriteString("**Metric Definitions**:\n")
|
||||
sb.WriteString("- Profit Factor: Total Profit ÷ Total Loss (>1 means profitable, >1.5 good, >2 excellent)\n")
|
||||
sb.WriteString("- Sharpe Ratio: (Avg Return - Risk-free Return) ÷ Std Dev of Returns (>1 good, >2 excellent)\n")
|
||||
sb.WriteString("- Win/Loss Ratio: Avg Win ÷ Avg Loss (>1.5 good, >2 excellent)\n")
|
||||
sb.WriteString("- Max Drawdown: Largest decline of the equity curve from peak to trough (<20% is low risk)\n\n")
|
||||
|
||||
// Data values
|
||||
sb.WriteString("**当前数据**:\n")
|
||||
sb.WriteString(fmt.Sprintf("- 总交易: %d 笔\n", stats.TotalTrades))
|
||||
sb.WriteString(fmt.Sprintf("- 盈利因子: %.2f\n", stats.ProfitFactor))
|
||||
sb.WriteString(fmt.Sprintf("- 夏普比率: %.2f\n", stats.SharpeRatio))
|
||||
sb.WriteString(fmt.Sprintf("- 盈亏比: %.2f\n", winLossRatio))
|
||||
sb.WriteString(fmt.Sprintf("- 总盈亏: %+.2f USDT\n", stats.TotalPnL))
|
||||
sb.WriteString(fmt.Sprintf("- 平均盈利: +%.2f USDT\n", stats.AvgWin))
|
||||
sb.WriteString(fmt.Sprintf("- 平均亏损: -%.2f USDT\n", stats.AvgLoss))
|
||||
sb.WriteString(fmt.Sprintf("- 最大回撤: %.1f%%\n\n", stats.MaxDrawdownPct))
|
||||
sb.WriteString("**Current Data**:\n")
|
||||
sb.WriteString(fmt.Sprintf("- Total Trades: %d\n", stats.TotalTrades))
|
||||
sb.WriteString(fmt.Sprintf("- Profit Factor: %.2f\n", stats.ProfitFactor))
|
||||
sb.WriteString(fmt.Sprintf("- Sharpe Ratio: %.2f\n", stats.SharpeRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Win/Loss Ratio: %.2f\n", winLossRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Total PnL: %+.2f USDT\n", stats.TotalPnL))
|
||||
sb.WriteString(fmt.Sprintf("- Avg Win: +%.2f USDT\n", stats.AvgWin))
|
||||
sb.WriteString(fmt.Sprintf("- Avg Loss: -%.2f USDT\n", stats.AvgLoss))
|
||||
sb.WriteString(fmt.Sprintf("- Max Drawdown: %.1f%%\n\n", stats.MaxDrawdownPct))
|
||||
|
||||
// Comprehensive analysis and decision guidance
|
||||
sb.WriteString("**决策参考**:\n")
|
||||
sb.WriteString("**Decision Reference**:\n")
|
||||
|
||||
// Provide specific recommendations based on statistics
|
||||
if stats.TotalTrades < 10 {
|
||||
sb.WriteString("- 样本量较小(<10笔),统计结果参考意义有限\n")
|
||||
sb.WriteString("- Small sample size (<10 trades), statistics have limited reference value\n")
|
||||
}
|
||||
|
||||
if stats.ProfitFactor >= 1.5 && stats.SharpeRatio >= 1 {
|
||||
sb.WriteString("- 📈 表现良好: 可以维持当前策略风格\n")
|
||||
sb.WriteString("- 📈 Good performance: you can keep the current strategy style\n")
|
||||
} else if stats.ProfitFactor >= 1.0 {
|
||||
sb.WriteString("- 📊 表现正常: 策略可行但有优化空间\n")
|
||||
sb.WriteString("- 📊 Normal performance: strategy is viable but has room for optimization\n")
|
||||
}
|
||||
|
||||
if stats.ProfitFactor < 1.0 {
|
||||
sb.WriteString("- ⚠️ 盈利因子<1: 亏损大于盈利,需要提高盈亏比,优化止盈止损\n")
|
||||
sb.WriteString("- ⚠️ Profit Factor <1: losses exceed profits, improve win/loss ratio and optimize stops/targets\n")
|
||||
}
|
||||
|
||||
if winLossRatio > 0 && winLossRatio < 1.5 {
|
||||
sb.WriteString("- ⚠️ 盈亏比偏低: 建议让利润奔跑,提高止盈目标\n")
|
||||
sb.WriteString("- ⚠️ Low win/loss ratio: let profits run and raise take-profit targets\n")
|
||||
}
|
||||
|
||||
if stats.MaxDrawdownPct > 30 {
|
||||
sb.WriteString("- ⚠️ 最大回撤过高: 建议降低仓位大小控制风险\n")
|
||||
sb.WriteString("- ⚠️ Max drawdown too high: reduce position size to control risk\n")
|
||||
} else if stats.MaxDrawdownPct < 10 {
|
||||
sb.WriteString("- ✅ 回撤控制良好: 风险管理有效\n")
|
||||
sb.WriteString("- ✅ Drawdown well controlled: risk management is effective\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
@@ -195,16 +195,16 @@ func formatTradingStatsZH(stats *TradingStats) string {
|
||||
// formatRecentTradesZH formats recent trades (Chinese)
|
||||
func formatRecentTradesZH(orders []RecentOrder) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## 最近完成的交易\n\n")
|
||||
sb.WriteString("## Recently Closed Trades\n\n")
|
||||
|
||||
for i, order := range orders {
|
||||
// Determine profit or loss
|
||||
profitOrLoss := "盈利"
|
||||
profitOrLoss := "Profit"
|
||||
if order.RealizedPnL < 0 {
|
||||
profitOrLoss = "亏损"
|
||||
profitOrLoss = "Loss"
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%d. %s %s | 进场 %.4f 出场 %.4f | %s: %+.2f USDT (%+.2f%%) | %s → %s (%s)\n",
|
||||
sb.WriteString(fmt.Sprintf("%d. %s %s | Entry %.4f Exit %.4f | %s: %+.2f USDT (%+.2f%%) | %s → %s (%s)\n",
|
||||
i+1,
|
||||
order.Symbol,
|
||||
order.Side,
|
||||
@@ -226,37 +226,37 @@ func formatRecentTradesZH(orders []RecentOrder) string {
|
||||
// formatCurrentPositionsZH formats current positions (Chinese)
|
||||
func formatCurrentPositionsZH(ctx *Context) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## 当前持仓\n\n")
|
||||
sb.WriteString("## Current Positions\n\n")
|
||||
|
||||
for i, pos := range ctx.Positions {
|
||||
// Calculate drawdown
|
||||
drawdown := pos.UnrealizedPnLPct - pos.PeakPnLPct
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%d. %s %s | ", i+1, pos.Symbol, strings.ToUpper(pos.Side)))
|
||||
sb.WriteString(fmt.Sprintf("进场 %.4f 当前 %.4f | ", pos.EntryPrice, pos.MarkPrice))
|
||||
sb.WriteString(fmt.Sprintf("数量 %.4f | ", pos.Quantity))
|
||||
sb.WriteString(fmt.Sprintf("仓位价值 %.2f USDT | ", pos.Quantity*pos.MarkPrice))
|
||||
sb.WriteString(fmt.Sprintf("盈亏 %+.2f%% | ", pos.UnrealizedPnLPct))
|
||||
sb.WriteString(fmt.Sprintf("盈亏金额 %+.2f USDT | ", pos.UnrealizedPnL))
|
||||
sb.WriteString(fmt.Sprintf("峰值盈亏 %.2f%% | ", pos.PeakPnLPct))
|
||||
sb.WriteString(fmt.Sprintf("杠杆 %dx | ", pos.Leverage))
|
||||
sb.WriteString(fmt.Sprintf("保证金 %.0f USDT | ", pos.MarginUsed))
|
||||
sb.WriteString(fmt.Sprintf("强平价 %.4f\n", pos.LiquidationPrice))
|
||||
sb.WriteString(fmt.Sprintf("Entry %.4f Current %.4f | ", pos.EntryPrice, pos.MarkPrice))
|
||||
sb.WriteString(fmt.Sprintf("Quantity %.4f | ", pos.Quantity))
|
||||
sb.WriteString(fmt.Sprintf("Position Value %.2f USDT | ", pos.Quantity*pos.MarkPrice))
|
||||
sb.WriteString(fmt.Sprintf("PnL %+.2f%% | ", pos.UnrealizedPnLPct))
|
||||
sb.WriteString(fmt.Sprintf("PnL Amount %+.2f USDT | ", pos.UnrealizedPnL))
|
||||
sb.WriteString(fmt.Sprintf("Peak PnL %.2f%% | ", pos.PeakPnLPct))
|
||||
sb.WriteString(fmt.Sprintf("Leverage %dx | ", pos.Leverage))
|
||||
sb.WriteString(fmt.Sprintf("Margin %.0f USDT | ", pos.MarginUsed))
|
||||
sb.WriteString(fmt.Sprintf("Liq Price %.4f\n", pos.LiquidationPrice))
|
||||
|
||||
// Add analysis hints
|
||||
if drawdown < -0.30*pos.PeakPnLPct && pos.PeakPnLPct > 0.02 {
|
||||
sb.WriteString(fmt.Sprintf(" ⚠️ **止盈提示**: 当前盈亏从峰值 %.2f%% 回撤到 %.2f%%,回撤幅度 %.2f%%,建议考虑止盈\n",
|
||||
sb.WriteString(fmt.Sprintf(" ⚠️ **Take-Profit Hint**: Current PnL retraced from peak %.2f%% to %.2f%%, drawdown %.2f%%, consider taking profit\n",
|
||||
pos.PeakPnLPct, pos.UnrealizedPnLPct, (drawdown/pos.PeakPnLPct)*100))
|
||||
}
|
||||
|
||||
if pos.UnrealizedPnLPct < -4.0 {
|
||||
sb.WriteString(" ⚠️ **止损提示**: 亏损接近-5%止损线,建议考虑止损\n")
|
||||
sb.WriteString(" ⚠️ **Stop-Loss Hint**: Loss approaching the -5% stop-loss line, consider stopping out\n")
|
||||
}
|
||||
|
||||
// Show current price (if market data available)
|
||||
if ctx.MarketDataMap != nil {
|
||||
if mdata, ok := ctx.MarketDataMap[pos.Symbol]; ok {
|
||||
sb.WriteString(fmt.Sprintf(" 📈 当前价格: %.4f\n", mdata.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf(" 📈 Current Price: %.4f\n", mdata.CurrentPrice))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func formatCurrentPositionsZH(ctx *Context) string {
|
||||
// formatCandidateCoinsZH formats candidate coins (Chinese)
|
||||
func formatCandidateCoinsZH(ctx *Context) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## 候选币种\n\n")
|
||||
sb.WriteString("## Candidate Coins\n\n")
|
||||
|
||||
for i, coin := range ctx.CandidateCoins {
|
||||
sb.WriteString(fmt.Sprintf("### %d. %s\n\n", i+1, coin.Symbol))
|
||||
@@ -277,7 +277,7 @@ func formatCandidateCoinsZH(ctx *Context) string {
|
||||
// Current price
|
||||
if ctx.MarketDataMap != nil {
|
||||
if mdata, ok := ctx.MarketDataMap[coin.Symbol]; ok {
|
||||
sb.WriteString(fmt.Sprintf("当前价格: %.4f\n\n", mdata.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf("Current Price: %.4f\n\n", mdata.CurrentPrice))
|
||||
|
||||
// Kline data (multi-timeframe)
|
||||
if mdata.TimeframeData != nil {
|
||||
@@ -289,7 +289,7 @@ func formatCandidateCoinsZH(ctx *Context) string {
|
||||
// OI data (if available)
|
||||
if ctx.OITopDataMap != nil {
|
||||
if oiData, ok := ctx.OITopDataMap[coin.Symbol]; ok {
|
||||
sb.WriteString(fmt.Sprintf("**持仓量变化**: OI排名 #%d | 变化 %+.2f%% (%+.2fM USDT) | 价格变化 %+.2f%%\n\n",
|
||||
sb.WriteString(fmt.Sprintf("**OI Change**: OI Rank #%d | Change %+.2f%% (%+.2fM USDT) | Price Change %+.2f%%\n\n",
|
||||
oiData.Rank,
|
||||
oiData.OIDeltaPercent,
|
||||
oiData.OIDeltaValue/1_000_000,
|
||||
@@ -297,17 +297,17 @@ func formatCandidateCoinsZH(ctx *Context) string {
|
||||
))
|
||||
|
||||
// OI interpretation
|
||||
oiChange := "增加"
|
||||
oiChange := "increase"
|
||||
if oiData.OIDeltaPercent < 0 {
|
||||
oiChange = "减少"
|
||||
oiChange = "decrease"
|
||||
}
|
||||
priceChange := "上涨"
|
||||
priceChange := "up"
|
||||
if oiData.PriceDeltaPercent < 0 {
|
||||
priceChange = "下跌"
|
||||
priceChange = "down"
|
||||
}
|
||||
|
||||
interpretation := getOIInterpretationZH(oiChange, priceChange)
|
||||
sb.WriteString(fmt.Sprintf("**市场解读**: %s\n\n", interpretation))
|
||||
sb.WriteString(fmt.Sprintf("**Market Interpretation**: %s\n\n", interpretation))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,9 +321,9 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD
|
||||
|
||||
for _, tf := range timeframes {
|
||||
if data, ok := tfData[tf]; ok && len(data.Klines) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("#### %s 时间框架 (从旧到新)\n\n", tf))
|
||||
sb.WriteString(fmt.Sprintf("#### %s Timeframe (oldest to newest)\n\n", tf))
|
||||
sb.WriteString("```\n")
|
||||
sb.WriteString("时间(UTC) 开盘 最高 最低 收盘 成交量\n")
|
||||
sb.WriteString("Time(UTC) Open High Low Close Volume\n")
|
||||
|
||||
// Only show the latest 30 klines
|
||||
startIdx := 0
|
||||
@@ -346,7 +346,7 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD
|
||||
|
||||
// Mark the last kline
|
||||
if len(data.Klines) > 0 {
|
||||
sb.WriteString(" <- 当前\n")
|
||||
sb.WriteString(" <- current\n")
|
||||
}
|
||||
|
||||
sb.WriteString("```\n\n")
|
||||
@@ -356,14 +356,13 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
|
||||
// getOIInterpretationZH returns OI change interpretation (Chinese)
|
||||
func getOIInterpretationZH(oiChange, priceChange string) string {
|
||||
if oiChange == "增加" && priceChange == "上涨" {
|
||||
if oiChange == "increase" && priceChange == "up" {
|
||||
return OIInterpretation.OIUp_PriceUp.ZH
|
||||
} else if oiChange == "增加" && priceChange == "下跌" {
|
||||
} else if oiChange == "increase" && priceChange == "down" {
|
||||
return OIInterpretation.OIUp_PriceDown.ZH
|
||||
} else if oiChange == "减少" && priceChange == "上涨" {
|
||||
} else if oiChange == "decrease" && priceChange == "up" {
|
||||
return OIInterpretation.OIDown_PriceUp.ZH
|
||||
} else {
|
||||
return OIInterpretation.OIDown_PriceDown.ZH
|
||||
@@ -621,7 +620,6 @@ func formatKlineDataEN(symbol string, tfData map[string]*market.TimeframeSeriesD
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
|
||||
// getOIInterpretationEN returns OI change interpretation (English)
|
||||
func getOIInterpretationEN(oiChange, priceChange string) string {
|
||||
if oiChange == "increase" && priceChange == "up" {
|
||||
|
||||
@@ -102,53 +102,53 @@ func BuildGridSystemPrompt(config *store.GridStrategyConfig, lang string) string
|
||||
}
|
||||
|
||||
func buildGridSystemPromptZh(config *store.GridStrategyConfig) string {
|
||||
return fmt.Sprintf(`# 你是一个专业的网格交易AI
|
||||
return fmt.Sprintf(`# You are a Professional Grid Trading AI
|
||||
|
||||
## 角色定义
|
||||
你是一个经验丰富的网格交易专家,负责管理 %s 的网格交易策略。你的任务是:
|
||||
1. 判断当前市场状态(震荡/趋势/高波动)
|
||||
2. 决定是否需要调整网格或暂停交易
|
||||
3. 管理每个网格层级的订单
|
||||
## Role Definition
|
||||
You are an experienced grid trading expert responsible for managing the grid trading strategy for %s. Your tasks are:
|
||||
1. Determine the current market state (ranging/trending/high volatility)
|
||||
2. Decide whether to adjust the grid or pause trading
|
||||
3. Manage orders at each grid level
|
||||
|
||||
## 网格配置
|
||||
- 交易对: %s
|
||||
- 网格层数: %d
|
||||
- 总投资: %.2f USDT
|
||||
- 杠杆: %dx
|
||||
- 价格分布: %s
|
||||
## Grid Configuration
|
||||
- Trading Pair: %s
|
||||
- Grid Levels: %d
|
||||
- Total Investment: %.2f USDT
|
||||
- Leverage: %dx
|
||||
- Price Distribution: %s
|
||||
|
||||
## 决策规则
|
||||
## Decision Rules
|
||||
|
||||
### 市场状态判断
|
||||
- **震荡市场** (适合网格): 布林带宽度 < 3%%, EMA20/50 距离 < 1%%, 价格在布林带中轨附近
|
||||
- **趋势市场** (暂停网格): 布林带宽度 > 4%%, EMA20/50 距离 > 2%%, 价格持续突破布林带
|
||||
- **高波动市场** (谨慎): ATR异常放大, 价格剧烈波动
|
||||
### Market State Judgment
|
||||
- **Ranging Market** (suitable for grid): Bollinger band width < 3%%, EMA20/50 distance < 1%%, price near the Bollinger middle band
|
||||
- **Trending Market** (pause grid): Bollinger band width > 4%%, EMA20/50 distance > 2%%, price continuously breaking out of the Bollinger bands
|
||||
- **High Volatility Market** (caution): abnormally expanding ATR, sharp price swings
|
||||
|
||||
### 可执行的操作
|
||||
- place_buy_limit: 在指定价格下买入限价单
|
||||
- place_sell_limit: 在指定价格下卖出限价单
|
||||
- cancel_order: 取消指定订单
|
||||
- cancel_all_orders: 取消所有订单
|
||||
- pause_grid: 暂停网格交易(趋势市场时)
|
||||
- resume_grid: 恢复网格交易(震荡市场时)
|
||||
- adjust_grid: 调整网格边界
|
||||
- hold: 保持当前状态不操作
|
||||
### Available Actions
|
||||
- place_buy_limit: place a buy limit order at the specified price
|
||||
- place_sell_limit: place a sell limit order at the specified price
|
||||
- cancel_order: cancel a specified order
|
||||
- cancel_all_orders: cancel all orders
|
||||
- pause_grid: pause grid trading (in a trending market)
|
||||
- resume_grid: resume grid trading (in a ranging market)
|
||||
- adjust_grid: adjust the grid boundaries
|
||||
- hold: keep the current state, take no action
|
||||
|
||||
## 输出格式
|
||||
输出JSON数组,每个决策包含:
|
||||
- symbol: 交易对
|
||||
- action: 操作类型
|
||||
- price: 价格(限价单用)
|
||||
- quantity: 数量
|
||||
- level_index: 网格层级索引
|
||||
- order_id: 订单ID(取消订单用)
|
||||
- confidence: 置信度 0-100
|
||||
- reasoning: 决策理由
|
||||
## Output Format
|
||||
Output a JSON array; each decision contains:
|
||||
- symbol: trading pair
|
||||
- action: action type
|
||||
- price: price (for limit orders)
|
||||
- quantity: quantity
|
||||
- level_index: grid level index
|
||||
- order_id: order ID (for canceling orders)
|
||||
- confidence: confidence 0-100
|
||||
- reasoning: decision reasoning
|
||||
|
||||
示例:
|
||||
Example:
|
||||
[
|
||||
{"symbol": "BTCUSDT", "action": "place_buy_limit", "price": 94000, "quantity": 0.01, "level_index": 2, "confidence": 85, "reasoning": "第2层价格接近,下买单"},
|
||||
{"symbol": "BTCUSDT", "action": "hold", "confidence": 90, "reasoning": "市场震荡,保持当前网格"}
|
||||
{"symbol": "BTCUSDT", "action": "place_buy_limit", "price": 94000, "quantity": 0.01, "level_index": 2, "confidence": 85, "reasoning": "Level 2 price is near, place a buy order"},
|
||||
{"symbol": "BTCUSDT", "action": "hold", "confidence": 90, "reasoning": "Market is ranging, keep the current grid"}
|
||||
]
|
||||
`, config.Symbol, config.Symbol, config.GridCount, config.TotalInvestment, config.Leverage, config.Distribution)
|
||||
}
|
||||
@@ -216,26 +216,26 @@ func BuildGridUserPrompt(ctx *GridContext, lang string) string {
|
||||
func buildGridUserPromptZh(ctx *GridContext) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## 当前时间: %s\n\n", ctx.CurrentTime))
|
||||
sb.WriteString(fmt.Sprintf("## Current Time: %s\n\n", ctx.CurrentTime))
|
||||
|
||||
// Market data section
|
||||
sb.WriteString("## 市场数据\n")
|
||||
sb.WriteString(fmt.Sprintf("- 当前价格: $%.2f\n", ctx.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf("- 1小时涨跌: %.2f%%\n", ctx.PriceChange1h))
|
||||
sb.WriteString(fmt.Sprintf("- 4小时涨跌: %.2f%%\n", ctx.PriceChange4h))
|
||||
sb.WriteString("## Market Data\n")
|
||||
sb.WriteString(fmt.Sprintf("- Current Price: $%.2f\n", ctx.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf("- 1h Change: %.2f%%\n", ctx.PriceChange1h))
|
||||
sb.WriteString(fmt.Sprintf("- 4h Change: %.2f%%\n", ctx.PriceChange4h))
|
||||
sb.WriteString(fmt.Sprintf("- ATR14: $%.2f (%.2f%%)\n", ctx.ATR14, ctx.ATR14/ctx.CurrentPrice*100))
|
||||
sb.WriteString(fmt.Sprintf("- 布林带: 上轨 $%.2f, 中轨 $%.2f, 下轨 $%.2f\n", ctx.BollingerUpper, ctx.BollingerMiddle, ctx.BollingerLower))
|
||||
sb.WriteString(fmt.Sprintf("- 布林带宽度: %.2f%%\n", ctx.BollingerWidth))
|
||||
sb.WriteString(fmt.Sprintf("- EMA20: $%.2f, EMA50: $%.2f, 距离: %.2f%%\n", ctx.EMA20, ctx.EMA50, ctx.EMADistance))
|
||||
sb.WriteString(fmt.Sprintf("- Bollinger Bands: Upper $%.2f, Middle $%.2f, Lower $%.2f\n", ctx.BollingerUpper, ctx.BollingerMiddle, ctx.BollingerLower))
|
||||
sb.WriteString(fmt.Sprintf("- Bollinger Width: %.2f%%\n", ctx.BollingerWidth))
|
||||
sb.WriteString(fmt.Sprintf("- EMA20: $%.2f, EMA50: $%.2f, Distance: %.2f%%\n", ctx.EMA20, ctx.EMA50, ctx.EMADistance))
|
||||
sb.WriteString(fmt.Sprintf("- RSI14: %.1f\n", ctx.RSI14))
|
||||
sb.WriteString(fmt.Sprintf("- MACD: %.4f, Signal: %.4f, Histogram: %.4f\n", ctx.MACD, ctx.MACDSignal, ctx.MACDHistogram))
|
||||
sb.WriteString(fmt.Sprintf("- 资金费率: %.4f%%\n", ctx.FundingRate*100))
|
||||
sb.WriteString(fmt.Sprintf("- Funding Rate: %.4f%%\n", ctx.FundingRate*100))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Box Indicator Section
|
||||
if ctx.BoxData != nil {
|
||||
sb.WriteString("## 箱体指标 (唐奇安通道)\n\n")
|
||||
sb.WriteString("| 箱体级别 | 上轨 | 下轨 | 宽度 |\n")
|
||||
sb.WriteString("## Box Indicator (Donchian Channel)\n\n")
|
||||
sb.WriteString("| Box Level | Upper | Lower | Width |\n")
|
||||
sb.WriteString("|----------|------|------|------|\n")
|
||||
|
||||
shortWidth := 0.0
|
||||
@@ -248,59 +248,59 @@ func buildGridUserPromptZh(ctx *GridContext) string {
|
||||
longWidth = (ctx.BoxData.LongUpper - ctx.BoxData.LongLower) / ctx.BoxData.CurrentPrice * 100
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("| 短期 (3天) | %.2f | %.2f | %.2f%% |\n",
|
||||
sb.WriteString(fmt.Sprintf("| Short (3d) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.ShortUpper, ctx.BoxData.ShortLower, shortWidth))
|
||||
sb.WriteString(fmt.Sprintf("| 中期 (10天) | %.2f | %.2f | %.2f%% |\n",
|
||||
sb.WriteString(fmt.Sprintf("| Mid (10d) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.MidUpper, ctx.BoxData.MidLower, midWidth))
|
||||
sb.WriteString(fmt.Sprintf("| 长期 (21天) | %.2f | %.2f | %.2f%% |\n",
|
||||
sb.WriteString(fmt.Sprintf("| Long (21d) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.LongUpper, ctx.BoxData.LongLower, longWidth))
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\n当前价格: %.2f\n", ctx.BoxData.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf("\nCurrent Price: %.2f\n", ctx.BoxData.CurrentPrice))
|
||||
|
||||
// Check position relative to boxes
|
||||
price := ctx.BoxData.CurrentPrice
|
||||
if price > ctx.BoxData.LongUpper || price < ctx.BoxData.LongLower {
|
||||
sb.WriteString("⚠️ 突破: 价格突破长期箱体!\n")
|
||||
sb.WriteString("⚠️ Breakout: price broke out of the long-term box!\n")
|
||||
} else if price > ctx.BoxData.MidUpper || price < ctx.BoxData.MidLower {
|
||||
sb.WriteString("⚠️ 警告: 价格接近长期箱体边界\n")
|
||||
sb.WriteString("⚠️ Warning: price is approaching the long-term box boundary\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Account section
|
||||
sb.WriteString("## 账户状态\n")
|
||||
sb.WriteString(fmt.Sprintf("- 总权益: $%.2f\n", ctx.TotalEquity))
|
||||
sb.WriteString(fmt.Sprintf("- 可用余额: $%.2f\n", ctx.AvailableBalance))
|
||||
sb.WriteString(fmt.Sprintf("- 当前持仓: %.4f (净头寸)\n", ctx.CurrentPosition))
|
||||
sb.WriteString(fmt.Sprintf("- 未实现盈亏: $%.2f\n", ctx.UnrealizedPnL))
|
||||
sb.WriteString("## Account Status\n")
|
||||
sb.WriteString(fmt.Sprintf("- Total Equity: $%.2f\n", ctx.TotalEquity))
|
||||
sb.WriteString(fmt.Sprintf("- Available Balance: $%.2f\n", ctx.AvailableBalance))
|
||||
sb.WriteString(fmt.Sprintf("- Current Position: %.4f (net position)\n", ctx.CurrentPosition))
|
||||
sb.WriteString(fmt.Sprintf("- Unrealized PnL: $%.2f\n", ctx.UnrealizedPnL))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid state section
|
||||
sb.WriteString("## 网格状态\n")
|
||||
sb.WriteString(fmt.Sprintf("- 网格范围: $%.2f - $%.2f\n", ctx.LowerPrice, ctx.UpperPrice))
|
||||
sb.WriteString(fmt.Sprintf("- 网格间距: $%.2f\n", ctx.GridSpacing))
|
||||
sb.WriteString(fmt.Sprintf("- 活跃订单数: %d\n", ctx.ActiveOrderCount))
|
||||
sb.WriteString(fmt.Sprintf("- 已成交层数: %d\n", ctx.FilledLevelCount))
|
||||
sb.WriteString(fmt.Sprintf("- 网格已暂停: %v\n", ctx.IsPaused))
|
||||
sb.WriteString("## Grid State\n")
|
||||
sb.WriteString(fmt.Sprintf("- Grid Range: $%.2f - $%.2f\n", ctx.LowerPrice, ctx.UpperPrice))
|
||||
sb.WriteString(fmt.Sprintf("- Grid Spacing: $%.2f\n", ctx.GridSpacing))
|
||||
sb.WriteString(fmt.Sprintf("- Active Orders: %d\n", ctx.ActiveOrderCount))
|
||||
sb.WriteString(fmt.Sprintf("- Filled Levels: %d\n", ctx.FilledLevelCount))
|
||||
sb.WriteString(fmt.Sprintf("- Grid Paused: %v\n", ctx.IsPaused))
|
||||
if ctx.CurrentDirection != "" {
|
||||
directionDescZh := map[string]string{
|
||||
"neutral": "中性 (50%买+50%卖)",
|
||||
"long": "做多 (100%买)",
|
||||
"short": "做空 (100%卖)",
|
||||
"long_bias": "偏多 (70%买+30%卖)",
|
||||
"short_bias": "偏空 (30%买+70%卖)",
|
||||
"neutral": "Neutral (50% buy + 50% sell)",
|
||||
"long": "Long (100% buy)",
|
||||
"short": "Short (100% sell)",
|
||||
"long_bias": "Long bias (70% buy + 30% sell)",
|
||||
"short_bias": "Short bias (30% buy + 70% sell)",
|
||||
}
|
||||
desc := directionDescZh[ctx.CurrentDirection]
|
||||
if desc == "" {
|
||||
desc = ctx.CurrentDirection
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- 网格方向: %s\n", desc))
|
||||
sb.WriteString(fmt.Sprintf("- Grid Direction: %s\n", desc))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid levels detail
|
||||
sb.WriteString("## 网格层级详情\n")
|
||||
sb.WriteString("| 层级 | 价格 | 状态 | 方向 | 订单数量 | 持仓数量 | 未实现盈亏 |\n")
|
||||
sb.WriteString("## Grid Level Details\n")
|
||||
sb.WriteString("| Level | Price | State | Side | Order Qty | Position Qty | Unrealized PnL |\n")
|
||||
sb.WriteString("|------|------|------|------|----------|----------|------------|\n")
|
||||
for _, level := range ctx.Levels {
|
||||
sb.WriteString(fmt.Sprintf("| %d | $%.2f | %s | %s | %.4f | %.4f | $%.2f |\n",
|
||||
@@ -310,16 +310,16 @@ func buildGridUserPromptZh(ctx *GridContext) string {
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Performance section
|
||||
sb.WriteString("## 绩效统计\n")
|
||||
sb.WriteString(fmt.Sprintf("- 总利润: $%.2f\n", ctx.TotalProfit))
|
||||
sb.WriteString(fmt.Sprintf("- 总交易次数: %d\n", ctx.TotalTrades))
|
||||
sb.WriteString(fmt.Sprintf("- 胜率: %.1f%%\n", float64(ctx.WinningTrades)/float64(max(ctx.TotalTrades, 1))*100))
|
||||
sb.WriteString(fmt.Sprintf("- 最大回撤: %.2f%%\n", ctx.MaxDrawdown))
|
||||
sb.WriteString(fmt.Sprintf("- 今日盈亏: $%.2f\n", ctx.DailyPnL))
|
||||
sb.WriteString("## Performance Statistics\n")
|
||||
sb.WriteString(fmt.Sprintf("- Total Profit: $%.2f\n", ctx.TotalProfit))
|
||||
sb.WriteString(fmt.Sprintf("- Total Trades: %d\n", ctx.TotalTrades))
|
||||
sb.WriteString(fmt.Sprintf("- Win Rate: %.1f%%\n", float64(ctx.WinningTrades)/float64(max(ctx.TotalTrades, 1))*100))
|
||||
sb.WriteString(fmt.Sprintf("- Max Drawdown: %.2f%%\n", ctx.MaxDrawdown))
|
||||
sb.WriteString(fmt.Sprintf("- Today's PnL: $%.2f\n", ctx.DailyPnL))
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("## 请分析以上数据,做出网格交易决策\n")
|
||||
sb.WriteString("输出JSON数组格式的决策列表。\n")
|
||||
sb.WriteString("## Analyze the data above and make grid trading decisions\n")
|
||||
sb.WriteString("Output the decision list as a JSON array.\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -41,43 +41,43 @@ func (pb *PromptBuilder) BuildUserPrompt(ctx *Context) string {
|
||||
return formattedData + pb.getDecisionRequirementsEN()
|
||||
}
|
||||
|
||||
// ========== Chinese Prompts ==========
|
||||
// ========== Chinese Prompts (translated to English) ==========
|
||||
|
||||
func (pb *PromptBuilder) buildSystemPromptZH() string {
|
||||
return `你是一个专业的量化交易AI助手,负责分析市场数据并做出交易决策。
|
||||
return `You are a professional quantitative trading AI assistant, responsible for analyzing market data and making trading decisions.
|
||||
|
||||
## 你的任务
|
||||
## Your Tasks
|
||||
|
||||
1. **分析账户状态**: 评估当前风险水平、保证金使用率、持仓情况
|
||||
2. **分析当前持仓**: 判断是否需要止盈、止损、加仓或持有
|
||||
3. **分析候选币种**: 评估新的交易机会,结合技术分析和资金流向
|
||||
4. **做出决策**: 输出明确的交易决策,包含详细的推理过程
|
||||
1. **Analyze account status**: Evaluate current risk level, margin usage, and position status
|
||||
2. **Analyze current positions**: Decide whether to take profit, stop loss, add to position, or hold
|
||||
3. **Analyze candidate symbols**: Evaluate new trading opportunities, combining technical analysis and capital flow
|
||||
4. **Make decisions**: Output clear trading decisions with detailed reasoning
|
||||
|
||||
## 决策原则
|
||||
## Decision Principles
|
||||
|
||||
### 风险优先
|
||||
- 保证金使用率不得超过30%
|
||||
- 单个持仓亏损达到-5%必须止损
|
||||
- 优先保护资本,再考虑盈利
|
||||
### Risk First
|
||||
- Margin usage must not exceed 30%
|
||||
- A single position losing -5% must be stopped out
|
||||
- Protect capital first, then consider profit
|
||||
|
||||
### 跟踪止盈
|
||||
- 当持仓盈亏从峰值回撤30%时,考虑部分或全部止盈
|
||||
- 例如:Peak PnL +5%,Current PnL +3.5% → 回撤了30%,应该止盈
|
||||
### Trailing Take-Profit
|
||||
- When position PnL retraces 30% from its peak, consider partial or full take-profit
|
||||
- For example: Peak PnL +5%, Current PnL +3.5% -> retraced 30%, should take profit
|
||||
|
||||
### 顺势交易
|
||||
- 只在多个时间框架趋势一致时进场
|
||||
- 结合持仓量(OI)变化判断资金流向真实性
|
||||
- OI增加+价格上涨 = 强多头趋势
|
||||
- OI减少+价格上涨 = 空头平仓(可能反转)
|
||||
### Trend Following
|
||||
- Enter only when multiple timeframes' trends agree
|
||||
- Use open interest (OI) change to judge the authenticity of capital flow
|
||||
- OI up + price up = strong bullish trend
|
||||
- OI down + price up = short covering (possible reversal)
|
||||
|
||||
### 分批操作
|
||||
- 分批建仓:第一次开仓不超过目标仓位的50%
|
||||
- 分批止盈:盈利3%平33%,盈利5%平50%,盈利8%全平
|
||||
- 只在盈利仓位上加仓,永远不要追亏损
|
||||
### Scaling
|
||||
- Scale in: the first open should not exceed 50% of the target position
|
||||
- Scale out: at +3% profit close 33%, at +5% close 50%, at +8% close all
|
||||
- Only add to profitable positions, never chase losses
|
||||
|
||||
## 输出格式要求
|
||||
## Output Format Requirements
|
||||
|
||||
**必须**使用以下JSON格式输出决策:
|
||||
You **must** output decisions in the following JSON format:
|
||||
|
||||
` + "```json" + `
|
||||
[
|
||||
@@ -89,37 +89,37 @@ func (pb *PromptBuilder) buildSystemPromptZH() string {
|
||||
"stop_loss": 42000,
|
||||
"take_profit": 48000,
|
||||
"confidence": 85,
|
||||
"reasoning": "详细的推理过程,说明为什么做出这个决策"
|
||||
"reasoning": "Detailed reasoning explaining why this decision was made"
|
||||
}
|
||||
]
|
||||
` + "```" + `
|
||||
|
||||
### 字段说明
|
||||
### Field Descriptions
|
||||
|
||||
- **symbol**: 交易对(必需)
|
||||
- **action**: 动作类型(必需)
|
||||
- HOLD: 持有当前仓位
|
||||
- PARTIAL_CLOSE: 部分平仓
|
||||
- FULL_CLOSE: 全部平仓
|
||||
- ADD_POSITION: 在现有仓位上加仓
|
||||
- OPEN_NEW: 开设新仓位
|
||||
- WAIT: 等待,不采取任何行动
|
||||
- **leverage**: 杠杆倍数(开新仓时必需)
|
||||
- **position_size_usd**: 仓位大小(USDT,开新仓时必需)
|
||||
- **stop_loss**: 止损价格(开新仓时建议提供)
|
||||
- **take_profit**: 止盈价格(开新仓时建议提供)
|
||||
- **confidence**: 信心度(0-100)
|
||||
- **reasoning**: 推理过程(必需,必须详细说明决策依据)
|
||||
- **symbol**: trading pair (required)
|
||||
- **action**: action type (required)
|
||||
- HOLD: hold the current position
|
||||
- PARTIAL_CLOSE: partially close the position
|
||||
- FULL_CLOSE: fully close the position
|
||||
- ADD_POSITION: add to an existing position
|
||||
- OPEN_NEW: open a new position
|
||||
- WAIT: wait, take no action
|
||||
- **leverage**: leverage multiple (required when opening a new position)
|
||||
- **position_size_usd**: position size (USDT, required when opening a new position)
|
||||
- **stop_loss**: stop-loss price (recommended when opening a new position)
|
||||
- **take_profit**: take-profit price (recommended when opening a new position)
|
||||
- **confidence**: confidence level (0-100)
|
||||
- **reasoning**: reasoning (required, must explain the decision basis in detail)
|
||||
|
||||
## 重要提醒
|
||||
## Important Reminders
|
||||
|
||||
1. **永远不要**混淆已实现盈亏和未实现盈亏
|
||||
2. **永远记得**考虑杠杆对盈亏的放大作用
|
||||
3. **永远关注**Peak PnL,这是判断止盈的关键指标
|
||||
4. **永远结合**持仓量(OI)变化来判断趋势真实性
|
||||
5. **永远遵守**风险管理规则,保护资本是第一位的
|
||||
1. **Never** confuse realized PnL with unrealized PnL
|
||||
2. **Always remember** to account for leverage amplifying PnL
|
||||
3. **Always watch** Peak PnL, the key metric for take-profit decisions
|
||||
4. **Always combine** open interest (OI) change to judge trend authenticity
|
||||
5. **Always follow** risk management rules; protecting capital comes first
|
||||
|
||||
现在,请仔细分析接下来提供的交易数据,并做出专业的决策。`
|
||||
Now, carefully analyze the trading data provided next and make a professional decision.`
|
||||
}
|
||||
|
||||
func (pb *PromptBuilder) getDecisionRequirementsZH() string {
|
||||
@@ -127,30 +127,30 @@ func (pb *PromptBuilder) getDecisionRequirementsZH() string {
|
||||
|
||||
---
|
||||
|
||||
## 📝 现在请做出决策
|
||||
## 📝 Now Make Your Decision
|
||||
|
||||
### 决策步骤
|
||||
### Decision Steps
|
||||
|
||||
1. **分析账户风险**:
|
||||
- 当前保证金使用率是否在安全范围?
|
||||
- 是否有足够资金开新仓?
|
||||
1. **Analyze account risk**:
|
||||
- Is the current margin usage within a safe range?
|
||||
- Is there enough capital to open new positions?
|
||||
|
||||
2. **分析现有持仓**(如果有):
|
||||
- 是否触发止损条件?
|
||||
- 是否触发跟踪止盈条件?
|
||||
- 是否适合加仓?
|
||||
2. **Analyze existing positions** (if any):
|
||||
- Are stop-loss conditions triggered?
|
||||
- Are trailing take-profit conditions triggered?
|
||||
- Is it suitable to add to the position?
|
||||
|
||||
3. **分析候选币种**(如果有):
|
||||
- 技术形态是否符合进场条件?
|
||||
- 持仓量变化是否支持趋势?
|
||||
- 多个时间框架是否共振?
|
||||
3. **Analyze candidate symbols** (if any):
|
||||
- Does the technical pattern meet entry conditions?
|
||||
- Does the open interest change support the trend?
|
||||
- Do multiple timeframes resonate?
|
||||
|
||||
4. **输出决策**:
|
||||
- 使用规定的JSON格式
|
||||
- 提供详细的推理过程
|
||||
- 给出明确的行动指令
|
||||
4. **Output the decision**:
|
||||
- Use the specified JSON format
|
||||
- Provide detailed reasoning
|
||||
- Give clear action instructions
|
||||
|
||||
### 输出示例
|
||||
### Output Example
|
||||
|
||||
` + "```json" + `
|
||||
[
|
||||
@@ -158,7 +158,7 @@ func (pb *PromptBuilder) getDecisionRequirementsZH() string {
|
||||
"symbol": "PIPPINUSDT",
|
||||
"action": "PARTIAL_CLOSE",
|
||||
"confidence": 85,
|
||||
"reasoning": "当前PnL +2.96%,接近历史峰值+2.99%(回撤仅0.03%)。建议部分平仓锁定利润,因为:1) 持仓时间仅11分钟,已获得3%收益;2) 5分钟K线显示价格接近短期阻力位;3) 成交量开始萎缩,上涨动能减弱。建议平仓50%,剩余仓位设置跟踪止盈在峰值回撤20%处。"
|
||||
"reasoning": "Current PnL +2.96%, close to the all-time peak +2.99% (only 0.03% retracement). Recommend partial close to lock in profit because: 1) holding time is only 11 minutes with 3% gain already; 2) the 5-minute candle shows price near short-term resistance; 3) volume is starting to shrink and upward momentum is weakening. Recommend closing 50%, with the remaining position set to a trailing take-profit at 20% retracement from peak."
|
||||
},
|
||||
{
|
||||
"symbol": "HUSDT",
|
||||
@@ -168,12 +168,12 @@ func (pb *PromptBuilder) getDecisionRequirementsZH() string {
|
||||
"stop_loss": 0.1560,
|
||||
"take_profit": 0.1720,
|
||||
"confidence": 75,
|
||||
"reasoning": "HUSDT在5分钟时间框架突破关键阻力位0.1630,持仓量1小时内增加+1.57M (+0.89%),配合价格上涨+4.92%,符合'OI增加+价格上涨'的强多头模式。15分钟和1小时时间框架均呈现上涨趋势,多周期共振。建议开仓做多,止损设在突破点下方-5%,止盈目标+8%。"
|
||||
"reasoning": "HUSDT broke the key resistance 0.1630 on the 5-minute timeframe, open interest increased +1.57M (+0.89%) within 1 hour, together with a price rise of +4.92%, matching the strong bullish 'OI up + price up' pattern. Both the 15-minute and 1-hour timeframes show an uptrend, multi-period resonance. Recommend opening long, with stop-loss set 5% below the breakout point and take-profit target +8%."
|
||||
}
|
||||
]
|
||||
` + "```" + `
|
||||
|
||||
**请立即输出你的决策(JSON格式)**:`
|
||||
**Output your decision immediately (JSON format)**:`
|
||||
}
|
||||
|
||||
// ========== English Prompts ==========
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestPromptBuilder 测试提示词构建器
|
||||
// TestPromptBuilder tests the prompt builder
|
||||
func TestPromptBuilder(t *testing.T) {
|
||||
t.Run("NewPromptBuilder", func(t *testing.T) {
|
||||
builderZH := NewPromptBuilder(LangChinese)
|
||||
@@ -31,17 +31,17 @@ func TestPromptBuilder(t *testing.T) {
|
||||
t.Fatal("System prompt is empty")
|
||||
}
|
||||
|
||||
// 验证包含关键内容
|
||||
// Verify it contains key content
|
||||
mustContain := []string{
|
||||
"量化交易AI助手",
|
||||
"分析账户状态",
|
||||
"分析当前持仓",
|
||||
"分析候选币种",
|
||||
"做出决策",
|
||||
"风险优先",
|
||||
"跟踪止盈",
|
||||
"顺势交易",
|
||||
"分批操作",
|
||||
"quantitative trading AI assistant",
|
||||
"Analyze account status",
|
||||
"Analyze current positions",
|
||||
"Analyze candidate symbols",
|
||||
"Make decisions",
|
||||
"Risk First",
|
||||
"Trailing Take-Profit",
|
||||
"Trend Following",
|
||||
"Scaling",
|
||||
"JSON",
|
||||
"symbol",
|
||||
"action",
|
||||
@@ -54,7 +54,7 @@ func TestPromptBuilder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证包含所有有效的action类型
|
||||
// Verify it contains all valid action types
|
||||
actions := []string{"HOLD", "PARTIAL_CLOSE", "FULL_CLOSE", "ADD_POSITION", "OPEN_NEW", "WAIT"}
|
||||
for _, action := range actions {
|
||||
if !strings.Contains(systemPrompt, action) {
|
||||
@@ -71,7 +71,7 @@ func TestPromptBuilder(t *testing.T) {
|
||||
t.Fatal("System prompt is empty")
|
||||
}
|
||||
|
||||
// 验证包含关键内容
|
||||
// Verify it contains key content
|
||||
mustContain := []string{
|
||||
"quantitative trading AI",
|
||||
"Analyze Account Status",
|
||||
@@ -96,7 +96,7 @@ func TestPromptBuilder(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("BuildUserPrompt", func(t *testing.T) {
|
||||
// 创建测试上下文
|
||||
// Create test context
|
||||
ctx := createTestContext()
|
||||
|
||||
builderZH := NewPromptBuilder(LangChinese)
|
||||
@@ -106,27 +106,27 @@ func TestPromptBuilder(t *testing.T) {
|
||||
t.Fatal("User prompt is empty")
|
||||
}
|
||||
|
||||
// 验证包含数据字典
|
||||
if !strings.Contains(userPromptZH, "数据字典") {
|
||||
// Verify it contains the data dictionary
|
||||
if !strings.Contains(userPromptZH, "Data Dictionary") {
|
||||
t.Error("User prompt should contain data dictionary")
|
||||
}
|
||||
|
||||
// 验证包含账户信息
|
||||
// Verify it contains account information
|
||||
if !strings.Contains(userPromptZH, "3079.40") { // Equity
|
||||
t.Error("User prompt should contain account equity")
|
||||
}
|
||||
|
||||
// 验证包含持仓信息
|
||||
// Verify it contains position information
|
||||
if !strings.Contains(userPromptZH, "PIPPINUSDT") {
|
||||
t.Error("User prompt should contain position symbol")
|
||||
}
|
||||
|
||||
// 验证包含决策要求
|
||||
if !strings.Contains(userPromptZH, "现在请做出决策") {
|
||||
// Verify it contains decision requirements
|
||||
if !strings.Contains(userPromptZH, "Now Make Your Decision") {
|
||||
t.Error("User prompt should contain decision requirements")
|
||||
}
|
||||
|
||||
// 英文版本
|
||||
// English version
|
||||
builderEN := NewPromptBuilder(LangEnglish)
|
||||
userPromptEN := builderEN.BuildUserPrompt(ctx)
|
||||
|
||||
@@ -140,7 +140,7 @@ func TestPromptBuilder(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidateDecisionFormat 测试决策格式验证
|
||||
// TestValidateDecisionFormat tests decision format validation
|
||||
func TestValidateDecisionFormat(t *testing.T) {
|
||||
t.Run("ValidDecision", func(t *testing.T) {
|
||||
decisions := []Decision{
|
||||
@@ -152,7 +152,7 @@ func TestValidateDecisionFormat(t *testing.T) {
|
||||
StopLoss: 42000,
|
||||
TakeProfit: 48000,
|
||||
Confidence: 85,
|
||||
Reasoning: "详细的推理过程",
|
||||
Reasoning: "Detailed reasoning",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -319,7 +319,7 @@ func TestValidateDecisionFormat(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
// OPEN_NEW需要额外字段
|
||||
// OPEN_NEW requires extra fields
|
||||
if action == "OPEN_NEW" {
|
||||
decisions[0].Leverage = 3
|
||||
decisions[0].PositionSizeUSD = 1000
|
||||
@@ -333,7 +333,7 @@ func TestValidateDecisionFormat(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormatDecisionExample 测试决策示例格式化
|
||||
// TestFormatDecisionExample tests decision example formatting
|
||||
func TestFormatDecisionExample(t *testing.T) {
|
||||
t.Run("Chinese", func(t *testing.T) {
|
||||
example := FormatDecisionExample(LangChinese)
|
||||
@@ -342,7 +342,7 @@ func TestFormatDecisionExample(t *testing.T) {
|
||||
t.Fatal("Decision example is empty")
|
||||
}
|
||||
|
||||
// 应该是有效的JSON
|
||||
// Should be valid JSON
|
||||
if !strings.HasPrefix(strings.TrimSpace(example), "[") {
|
||||
t.Error("Example should be a JSON array")
|
||||
}
|
||||
@@ -359,14 +359,14 @@ func TestFormatDecisionExample(t *testing.T) {
|
||||
t.Fatal("Decision example is empty")
|
||||
}
|
||||
|
||||
// 验证是有效的JSON格式
|
||||
// Verify it is valid JSON format
|
||||
if !strings.HasPrefix(strings.TrimSpace(example), "[") {
|
||||
t.Error("Example should be a JSON array")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkBuildSystemPrompt 性能测试
|
||||
// BenchmarkBuildSystemPrompt performance benchmark
|
||||
func BenchmarkBuildSystemPrompt(b *testing.B) {
|
||||
builder := NewPromptBuilder(LangChinese)
|
||||
|
||||
@@ -384,7 +384,7 @@ func BenchmarkBuildSystemPrompt(b *testing.B) {
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkBuildUserPrompt 性能测试
|
||||
// BenchmarkBuildUserPrompt performance benchmark
|
||||
func BenchmarkBuildUserPrompt(b *testing.B) {
|
||||
builder := NewPromptBuilder(LangChinese)
|
||||
ctx := createTestContext()
|
||||
@@ -403,7 +403,7 @@ func BenchmarkBuildUserPrompt(b *testing.B) {
|
||||
})
|
||||
}
|
||||
|
||||
// createTestContext 创建测试用的交易上下文
|
||||
// createTestContext creates a trading context for tests
|
||||
func createTestContext() *Context {
|
||||
return &Context{
|
||||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
|
||||
190
kernel/schema.go
190
kernel/schema.go
@@ -62,156 +62,156 @@ func (d BilingualFieldDef) GetDesc(lang Language) string {
|
||||
var DataDictionary = map[string]map[string]BilingualFieldDef{
|
||||
"AccountMetrics": {
|
||||
"Equity": {
|
||||
NameZH: "总权益",
|
||||
NameZH: "Total Equity",
|
||||
NameEN: "Total Equity",
|
||||
Unit: "USDT",
|
||||
FormulaZH: "可用余额 + 未实现盈亏",
|
||||
FormulaZH: "Available Balance + Unrealized PnL",
|
||||
FormulaEN: "Available Balance + Unrealized PnL",
|
||||
DescZH: "账户的实际净值,包含所有持仓的浮动盈亏",
|
||||
DescZH: "Actual net value of the account, including unrealized P&L of all positions",
|
||||
DescEN: "Actual account value including all unrealized P&L from positions",
|
||||
},
|
||||
"Balance": {
|
||||
NameZH: "可用余额",
|
||||
NameZH: "Available Balance",
|
||||
NameEN: "Available Balance",
|
||||
Unit: "USDT",
|
||||
FormulaZH: "初始资金 + 已实现盈亏",
|
||||
FormulaZH: "Initial Capital + Realized PnL",
|
||||
FormulaEN: "Initial Capital + Realized PnL",
|
||||
DescZH: "可用于开新仓位的资金,不包括已用保证金",
|
||||
DescZH: "Funds available for opening new positions, excluding used margin",
|
||||
DescEN: "Available funds for opening new positions, excluding used margin",
|
||||
},
|
||||
"PnL": {
|
||||
NameZH: "总盈亏百分比",
|
||||
NameZH: "Total PnL Percentage",
|
||||
NameEN: "Total PnL Percentage",
|
||||
Unit: "%",
|
||||
FormulaZH: "(总权益 - 初始资金) / 初始资金 × 100",
|
||||
FormulaZH: "(Total Equity - Initial Capital) / Initial Capital × 100",
|
||||
FormulaEN: "(Total Equity - Initial Capital) / Initial Capital × 100",
|
||||
DescZH: "自系统启动以来的总收益率,+15.87%表示盈利15.87%",
|
||||
DescZH: "Total return since system startup, +15.87% means 15.87% profit",
|
||||
DescEN: "Total return since inception, +15.87% means 15.87% profit",
|
||||
},
|
||||
"Margin": {
|
||||
NameZH: "保证金使用率",
|
||||
NameZH: "Margin Usage Rate",
|
||||
NameEN: "Margin Usage Rate",
|
||||
Unit: "%",
|
||||
FormulaZH: "已用保证金合计 / 总权益 × 100",
|
||||
FormulaZH: "Total Used Margin / Total Equity × 100",
|
||||
FormulaEN: "Total Used Margin / Total Equity × 100",
|
||||
DescZH: "该值越高,账户风险越大。安全值<30%,危险值>70%",
|
||||
DescZH: "The higher this value, the greater the account risk. Safe <30%, Dangerous >70%",
|
||||
DescEN: "Higher value = higher risk. Safe <30%, Dangerous >70%",
|
||||
},
|
||||
},
|
||||
|
||||
"TradeMetrics": {
|
||||
"Entry": {
|
||||
NameZH: "进场价",
|
||||
NameZH: "Entry Price",
|
||||
NameEN: "Entry Price",
|
||||
Unit: "USDT",
|
||||
DescZH: "开仓时的平均价格",
|
||||
DescZH: "Average price when opening the position",
|
||||
DescEN: "Average price when opening position",
|
||||
},
|
||||
"Exit": {
|
||||
NameZH: "出场价",
|
||||
NameZH: "Exit Price",
|
||||
NameEN: "Exit Price",
|
||||
Unit: "USDT",
|
||||
DescZH: "平仓时的平均价格",
|
||||
DescZH: "Average price when closing the position",
|
||||
DescEN: "Average price when closing position",
|
||||
},
|
||||
"Profit": {
|
||||
NameZH: "已实现盈亏",
|
||||
NameZH: "Realized PnL",
|
||||
NameEN: "Realized PnL",
|
||||
Unit: "USDT",
|
||||
FormulaZH: "(出场价 - 进场价) / 进场价 × 杠杆 × 仓位价值",
|
||||
FormulaZH: "(Exit Price - Entry Price) / Entry Price × Leverage × Position Value",
|
||||
FormulaEN: "(Exit Price - Entry Price) / Entry Price × Leverage × Position Value",
|
||||
DescZH: "已平仓交易的实际盈亏,包含手续费。正值=盈利,负值=亏损",
|
||||
DescZH: "Actual P&L of closed trades, including fees. Positive=profit, Negative=loss",
|
||||
DescEN: "Actual profit/loss of closed trades including fees. Positive=profit, Negative=loss",
|
||||
},
|
||||
"PnL%": {
|
||||
NameZH: "盈亏百分比",
|
||||
NameZH: "PnL Percentage",
|
||||
NameEN: "PnL Percentage",
|
||||
Unit: "%",
|
||||
FormulaZH: "(出场价 - 进场价) / 进场价 × 杠杆 × 100",
|
||||
FormulaZH: "(Exit - Entry) / Entry × Leverage × 100",
|
||||
FormulaEN: "(Exit - Entry) / Entry × Leverage × 100",
|
||||
DescZH: "已平仓交易的收益率,+6.71%表示盈利6.71%",
|
||||
DescZH: "Return on a closed trade, +6.71% means 6.71% profit",
|
||||
DescEN: "Return on closed trade, +6.71% means 6.71% profit",
|
||||
},
|
||||
"HoldDuration": {
|
||||
NameZH: "持仓时长",
|
||||
NameZH: "Holding Duration",
|
||||
NameEN: "Holding Duration",
|
||||
Unit: "minutes",
|
||||
DescZH: "从开仓到平仓的时间。<15分钟=超短线,15分钟-4小时=日内,>4小时=波段",
|
||||
DescZH: "Time from open to close. <15min=scalping, 15min-4h=intraday, >4h=swing",
|
||||
DescEN: "Time from open to close. <15min=scalping, 15min-4h=intraday, >4h=swing",
|
||||
},
|
||||
},
|
||||
|
||||
"PositionMetrics": {
|
||||
"UnrealizedPnL%": {
|
||||
NameZH: "未实现盈亏百分比",
|
||||
NameZH: "Unrealized PnL Percentage",
|
||||
NameEN: "Unrealized PnL Percentage",
|
||||
Unit: "%",
|
||||
FormulaZH: "(当前价 - 进场价) / 进场价 × 杠杆 × 100",
|
||||
FormulaZH: "(Current Price - Entry Price) / Entry Price × Leverage × 100",
|
||||
FormulaEN: "(Current Price - Entry Price) / Entry Price × Leverage × 100",
|
||||
DescZH: "当前持仓的浮动盈亏,未平仓前是浮动的",
|
||||
DescZH: "Floating P&L of the current position, fluctuating until closed",
|
||||
DescEN: "Floating P&L of current position, not realized until closed",
|
||||
},
|
||||
"PeakPnL%": {
|
||||
NameZH: "峰值盈亏百分比",
|
||||
NameZH: "Peak PnL Percentage",
|
||||
NameEN: "Peak PnL Percentage",
|
||||
Unit: "%",
|
||||
DescZH: "该持仓曾经达到的最高未实现盈亏。用于判断是否需要止盈",
|
||||
DescZH: "Highest unrealized P&L this position has reached. Used to decide whether to take profit",
|
||||
DescEN: "Historical max unrealized PnL for this position. Used for take-profit decisions",
|
||||
},
|
||||
"Drawdown": {
|
||||
NameZH: "从峰值回撤",
|
||||
NameZH: "Drawdown from Peak",
|
||||
NameEN: "Drawdown from Peak",
|
||||
Unit: "%",
|
||||
FormulaZH: "当前盈亏% - 峰值盈亏%",
|
||||
FormulaZH: "Current PnL% - Peak PnL%",
|
||||
FormulaEN: "Current PnL% - Peak PnL%",
|
||||
DescZH: "负值表示正在回撤。例如:峰值+5%,当前+3%,回撤=-2%",
|
||||
DescZH: "Negative value means pulling back. E.g., Peak +5%, Current +3%, Drawdown = -2%",
|
||||
DescEN: "Negative = pulling back. E.g., Peak +5%, Current +3%, Drawdown = -2%",
|
||||
},
|
||||
"Leverage": {
|
||||
NameZH: "杠杆倍数",
|
||||
NameZH: "Leverage",
|
||||
NameEN: "Leverage",
|
||||
Unit: "x",
|
||||
DescZH: "3x表示价格变动1%,持仓盈亏变动3%。杠杆越高,风险越大",
|
||||
DescZH: "3x means a 1% price move = 3% position PnL. Higher leverage = higher risk",
|
||||
DescEN: "3x means 1% price move = 3% position PnL. Higher leverage = higher risk",
|
||||
},
|
||||
"Margin": {
|
||||
NameZH: "占用保证金",
|
||||
NameZH: "Margin Used",
|
||||
NameEN: "Margin Used",
|
||||
Unit: "USDT",
|
||||
FormulaZH: "仓位价值 / 杠杆",
|
||||
FormulaZH: "Position Value / Leverage",
|
||||
FormulaEN: "Position Value / Leverage",
|
||||
DescZH: "该仓位锁定的保证金金额",
|
||||
DescZH: "Amount of margin locked for this position",
|
||||
DescEN: "Collateral locked for this position",
|
||||
},
|
||||
"LiqPrice": {
|
||||
NameZH: "强平价格",
|
||||
NameZH: "Liquidation Price",
|
||||
NameEN: "Liquidation Price",
|
||||
Unit: "USDT",
|
||||
DescZH: "价格触及此值时会被强制平仓。0.0000表示无爆仓风险",
|
||||
DescZH: "Position will be force-closed when price reaches this value. 0.0000 = no liquidation risk",
|
||||
DescEN: "Price at which position will be force-closed. 0.0000 = no liquidation risk",
|
||||
},
|
||||
},
|
||||
|
||||
"MarketData": {
|
||||
"Volume": {
|
||||
NameZH: "成交量",
|
||||
NameZH: "Volume",
|
||||
NameEN: "Volume",
|
||||
Unit: "base asset",
|
||||
DescZH: "该时间段的交易量",
|
||||
DescZH: "Trading volume in this period",
|
||||
DescEN: "Trading volume in this period",
|
||||
},
|
||||
"OI": {
|
||||
NameZH: "持仓量",
|
||||
NameZH: "Open Interest",
|
||||
NameEN: "Open Interest",
|
||||
Unit: "USDT",
|
||||
DescZH: "未平仓合约的总价值。持仓量增加=资金流入,减少=资金流出",
|
||||
DescZH: "Total value of open contracts. Increasing OI = capital inflow, decreasing = outflow",
|
||||
DescEN: "Total value of open contracts. Increasing OI = capital inflow, decreasing = outflow",
|
||||
},
|
||||
"OIChange": {
|
||||
NameZH: "持仓量变化",
|
||||
NameZH: "OI Change",
|
||||
NameEN: "OI Change",
|
||||
Unit: "USDT & %",
|
||||
DescZH: "1小时内持仓量的变化。用于判断市场真实资金流向",
|
||||
DescZH: "OI change within 1 hour. Used to judge the real market capital flow direction",
|
||||
DescEN: "OI change in 1 hour. Used to determine real capital flow direction",
|
||||
},
|
||||
},
|
||||
@@ -256,30 +256,30 @@ var TradingRules = struct {
|
||||
RiskManagement: map[string]BilingualRuleDef{
|
||||
"MaxMarginUsage": {
|
||||
Value: 0.30,
|
||||
DescZH: "保证金使用率不得超过30%",
|
||||
DescZH: "Margin usage must not exceed 30%",
|
||||
DescEN: "Margin usage must not exceed 30%",
|
||||
ReasonZH: "保留70%的资金应对极端行情和追加保证金",
|
||||
ReasonZH: "Reserve 70% capital for extreme market conditions and margin calls",
|
||||
ReasonEN: "Reserve 70% capital for extreme market conditions and margin calls",
|
||||
},
|
||||
"MaxPositionLoss": {
|
||||
Value: -0.05,
|
||||
DescZH: "单个持仓亏损达到-5%时必须止损",
|
||||
DescZH: "Must stop-loss when single position loss reaches -5%",
|
||||
DescEN: "Must stop-loss when single position loss reaches -5%",
|
||||
ReasonZH: "避免单笔交易造成过大损失",
|
||||
ReasonZH: "Prevent excessive loss from single trade",
|
||||
ReasonEN: "Prevent excessive loss from single trade",
|
||||
},
|
||||
"MaxDailyLoss": {
|
||||
Value: -0.10,
|
||||
DescZH: "单日亏损达到-10%时停止交易",
|
||||
DescZH: "Stop trading when daily loss reaches -10%",
|
||||
DescEN: "Stop trading when daily loss reaches -10%",
|
||||
ReasonZH: "防止情绪化交易导致连续亏损",
|
||||
ReasonZH: "Prevent emotional trading leading to consecutive losses",
|
||||
ReasonEN: "Prevent emotional trading leading to consecutive losses",
|
||||
},
|
||||
"PositionSizeLimit": {
|
||||
Value: 0.15,
|
||||
DescZH: "单个仓位不得超过总权益的15%",
|
||||
DescZH: "Single position must not exceed 15% of total equity",
|
||||
DescEN: "Single position must not exceed 15% of total equity",
|
||||
ReasonZH: "避免过度集中风险",
|
||||
ReasonZH: "Avoid excessive risk concentration",
|
||||
ReasonEN: "Avoid excessive risk concentration",
|
||||
},
|
||||
},
|
||||
@@ -287,16 +287,16 @@ var TradingRules = struct {
|
||||
EntrySignals: map[string]BilingualRuleDef{
|
||||
"VolumeSpike": {
|
||||
Value: 2.0,
|
||||
DescZH: "成交量是平均值的2倍以上时考虑进场",
|
||||
DescZH: "Consider entry when volume is 2x above average",
|
||||
DescEN: "Consider entry when volume is 2x above average",
|
||||
ReasonZH: "放量突破通常意味着强趋势",
|
||||
ReasonZH: "Volume breakout usually indicates strong trend",
|
||||
ReasonEN: "Volume breakout usually indicates strong trend",
|
||||
},
|
||||
"OIChangeThreshold": {
|
||||
Value: 0.02,
|
||||
DescZH: "持仓量1小时内变化超过2%视为显著变化",
|
||||
DescZH: "OI change >2% in 1 hour is considered significant",
|
||||
DescEN: "OI change >2% in 1 hour is considered significant",
|
||||
ReasonZH: "大额资金进出会导致持仓量显著变化",
|
||||
ReasonZH: "Large capital flows cause significant OI changes",
|
||||
ReasonEN: "Large capital flows cause significant OI changes",
|
||||
},
|
||||
},
|
||||
@@ -304,16 +304,16 @@ var TradingRules = struct {
|
||||
ExitSignals: map[string]BilingualRuleDef{
|
||||
"TrailingStop": {
|
||||
Value: 0.30,
|
||||
DescZH: "当盈亏从峰值回撤30%时平仓止盈",
|
||||
DescZH: "Close position when PnL pulls back 30% from peak",
|
||||
DescEN: "Close position when PnL pulls back 30% from peak",
|
||||
ReasonZH: "锁定大部分利润,避免盈利回吐。例如:峰值+5%,回撤到+3.5%时平仓",
|
||||
ReasonZH: "Lock in most profits, avoid profit giveback. E.g., Peak +5%, close at +3.5%",
|
||||
ReasonEN: "Lock in most profits, avoid profit giveback. E.g., Peak +5%, close at +3.5%",
|
||||
},
|
||||
"StopLoss": {
|
||||
Value: -0.05,
|
||||
DescZH: "硬止损设置在-5%",
|
||||
DescZH: "Hard stop-loss at -5%",
|
||||
DescEN: "Hard stop-loss at -5%",
|
||||
ReasonZH: "严格控制单笔最大损失",
|
||||
ReasonZH: "Strictly control maximum single-trade loss",
|
||||
ReasonEN: "Strictly control maximum single-trade loss",
|
||||
},
|
||||
},
|
||||
@@ -321,9 +321,9 @@ var TradingRules = struct {
|
||||
PositionControl: map[string]BilingualRuleDef{
|
||||
"ScaleIn": {
|
||||
Value: map[string]interface{}{"enabled": true, "max_additions": 2, "price_requirement": 0.01},
|
||||
DescZH: "只在盈利仓位上加仓,最多加2次,价格需比平均成本高1%",
|
||||
DescZH: "Only add to winning positions, max 2 additions, price must be 1% above avg cost",
|
||||
DescEN: "Only add to winning positions, max 2 additions, price must be 1% above avg cost",
|
||||
ReasonZH: "顺势加仓,不追亏损",
|
||||
ReasonZH: "Add to winners, never average down losers",
|
||||
ReasonEN: "Add to winners, never average down losers",
|
||||
},
|
||||
"ScaleOut": {
|
||||
@@ -332,9 +332,9 @@ var TradingRules = struct {
|
||||
{"pnl": 0.05, "close_pct": 0.50},
|
||||
{"pnl": 0.08, "close_pct": 1.00},
|
||||
},
|
||||
DescZH: "分批止盈:盈利3%时平33%,5%时平50%,8%时全平",
|
||||
DescZH: "Scale-out: Close 33% at +3%, 50% at +5%, 100% at +8%",
|
||||
DescEN: "Scale-out: Close 33% at +3%, 50% at +5%, 100% at +8%",
|
||||
ReasonZH: "在保证利润的同时让盈利奔跑",
|
||||
ReasonZH: "Lock profits while letting winners run",
|
||||
ReasonEN: "Lock profits while letting winners run",
|
||||
},
|
||||
},
|
||||
@@ -367,28 +367,28 @@ var OIInterpretation = OIInterpretationType{
|
||||
ZH string
|
||||
EN string
|
||||
}{
|
||||
ZH: "强多头趋势(新多单开仓,资金流入做多)",
|
||||
ZH: "Strong bullish trend (new longs opening, capital flowing into long positions)",
|
||||
EN: "Strong bullish trend (new longs opening, capital flowing into long positions)",
|
||||
},
|
||||
OIUp_PriceDown: struct {
|
||||
ZH string
|
||||
EN string
|
||||
}{
|
||||
ZH: "强空头趋势(新空单开仓,资金流入做空)",
|
||||
ZH: "Strong bearish trend (new shorts opening, capital flowing into short positions)",
|
||||
EN: "Strong bearish trend (new shorts opening, capital flowing into short positions)",
|
||||
},
|
||||
OIDown_PriceUp: struct {
|
||||
ZH string
|
||||
EN string
|
||||
}{
|
||||
ZH: "空头平仓(空头止损离场,可能出现反转)",
|
||||
ZH: "Shorts covering (shorts stopped out, potential reversal)",
|
||||
EN: "Shorts covering (shorts stopped out, potential reversal)",
|
||||
},
|
||||
OIDown_PriceDown: struct {
|
||||
ZH string
|
||||
EN string
|
||||
}{
|
||||
ZH: "多头平仓(多头止损离场,可能出现反转)",
|
||||
ZH: "Longs closing (longs stopped out, potential reversal)",
|
||||
EN: "Longs closing (longs stopped out, potential reversal)",
|
||||
},
|
||||
}
|
||||
@@ -407,35 +407,35 @@ type CommonMistake struct {
|
||||
|
||||
var CommonMistakes = []CommonMistake{
|
||||
{
|
||||
ErrorZH: "混淆已实现盈亏和未实现盈亏",
|
||||
ErrorZH: "Confusing realized and unrealized P&L",
|
||||
ErrorEN: "Confusing realized and unrealized P&L",
|
||||
ExampleZH: "将历史交易的盈亏与当前持仓的盈亏相加",
|
||||
ExampleZH: "Adding historical trade P&L with current position P&L",
|
||||
ExampleEN: "Adding historical trade P&L with current position P&L",
|
||||
CorrectZH: "已实现盈亏已经计入账户余额,不应重复计算",
|
||||
CorrectZH: "Realized P&L is already included in account balance, don't double count",
|
||||
CorrectEN: "Realized P&L is already included in account balance, don't double count",
|
||||
},
|
||||
{
|
||||
ErrorZH: "忽略杠杆对盈亏的影响",
|
||||
ErrorZH: "Ignoring leverage's impact on P&L",
|
||||
ErrorEN: "Ignoring leverage's impact on P&L",
|
||||
ExampleZH: "价格涨1%,认为盈利1%",
|
||||
ExampleZH: "Price up 1%, thinking profit is 1%",
|
||||
ExampleEN: "Price up 1%, thinking profit is 1%",
|
||||
CorrectZH: "3x杠杆时,价格涨1%,实际盈利约3%",
|
||||
CorrectZH: "With 3x leverage, 1% price move = ~3% P&L",
|
||||
CorrectEN: "With 3x leverage, 1% price move = ~3% P&L",
|
||||
},
|
||||
{
|
||||
ErrorZH: "不理解Peak PnL的重要性",
|
||||
ErrorZH: "Not understanding Peak PnL's importance",
|
||||
ErrorEN: "Not understanding Peak PnL's importance",
|
||||
ExampleZH: "只关注当前PnL,不关注回撤",
|
||||
ExampleZH: "Only watching current PnL, ignoring drawdown",
|
||||
ExampleEN: "Only watching current PnL, ignoring drawdown",
|
||||
CorrectZH: "当前PnL接近Peak PnL时,应考虑止盈以锁定利润",
|
||||
CorrectZH: "When current PnL near Peak PnL, consider taking profit to lock in gains",
|
||||
CorrectEN: "When current PnL near Peak PnL, consider taking profit to lock in gains",
|
||||
},
|
||||
{
|
||||
ErrorZH: "忽略持仓量(OI)变化",
|
||||
ErrorZH: "Ignoring Open Interest changes",
|
||||
ErrorEN: "Ignoring Open Interest changes",
|
||||
ExampleZH: "只看价格K线,不看资金流向",
|
||||
ExampleZH: "Only watching price candles, not capital flows",
|
||||
ExampleEN: "Only watching price candles, not capital flows",
|
||||
CorrectZH: "结合OI变化判断趋势的真实性和持续性",
|
||||
CorrectZH: "Use OI changes to validate trend authenticity and sustainability",
|
||||
CorrectEN: "Use OI changes to validate trend authenticity and sustainability",
|
||||
},
|
||||
}
|
||||
@@ -452,39 +452,39 @@ func GetSchemaPrompt(lang Language) string {
|
||||
|
||||
// getSchemaPromptZH generates the Chinese prompt
|
||||
func getSchemaPromptZH() string {
|
||||
prompt := "# 📖 数据字典与交易规则\n\n"
|
||||
prompt += "## 📊 字段含义说明\n\n"
|
||||
prompt := "# 📖 Data Dictionary & Trading Rules\n\n"
|
||||
prompt += "## 📊 Field Definitions\n\n"
|
||||
|
||||
// Account metrics
|
||||
prompt += "### 账户指标\n"
|
||||
prompt += "### Account Metrics\n"
|
||||
for key, field := range DataDictionary["AccountMetrics"] {
|
||||
prompt += formatFieldDefZH(key, field)
|
||||
}
|
||||
|
||||
// Trade metrics
|
||||
prompt += "\n### 交易指标\n"
|
||||
prompt += "\n### Trade Metrics\n"
|
||||
for key, field := range DataDictionary["TradeMetrics"] {
|
||||
prompt += formatFieldDefZH(key, field)
|
||||
}
|
||||
|
||||
// Position metrics
|
||||
prompt += "\n### 持仓指标\n"
|
||||
prompt += "\n### Position Metrics\n"
|
||||
for key, field := range DataDictionary["PositionMetrics"] {
|
||||
prompt += formatFieldDefZH(key, field)
|
||||
}
|
||||
|
||||
// Market data
|
||||
prompt += "\n### 市场数据\n"
|
||||
prompt += "\n### Market Data\n"
|
||||
for key, field := range DataDictionary["MarketData"] {
|
||||
prompt += formatFieldDefZH(key, field)
|
||||
}
|
||||
|
||||
// OI interpretation
|
||||
prompt += "\n## 💹 持仓量(OI)变化解读\n\n"
|
||||
prompt += "- **OI增加 + 价格上涨**: " + OIInterpretation.OIUp_PriceUp.ZH + "\n"
|
||||
prompt += "- **OI增加 + 价格下跌**: " + OIInterpretation.OIUp_PriceDown.ZH + "\n"
|
||||
prompt += "- **OI减少 + 价格上涨**: " + OIInterpretation.OIDown_PriceUp.ZH + "\n"
|
||||
prompt += "- **OI减少 + 价格下跌**: " + OIInterpretation.OIDown_PriceDown.ZH + "\n"
|
||||
prompt += "\n## 💹 Open Interest (OI) Change Interpretation\n\n"
|
||||
prompt += "- **OI Up + Price Up**: " + OIInterpretation.OIUp_PriceUp.ZH + "\n"
|
||||
prompt += "- **OI Up + Price Down**: " + OIInterpretation.OIUp_PriceDown.ZH + "\n"
|
||||
prompt += "- **OI Down + Price Up**: " + OIInterpretation.OIDown_PriceUp.ZH + "\n"
|
||||
prompt += "- **OI Down + Price Down**: " + OIInterpretation.OIDown_PriceDown.ZH + "\n"
|
||||
|
||||
return prompt
|
||||
}
|
||||
@@ -530,12 +530,12 @@ func getSchemaPromptEN() string {
|
||||
|
||||
// formatFieldDefZH formats a field definition in Chinese
|
||||
func formatFieldDefZH(key string, field BilingualFieldDef) string {
|
||||
result := "- **" + key + "**(" + field.NameZH + "): " + field.DescZH
|
||||
result := "- **" + key + "** (" + field.NameZH + "): " + field.DescZH
|
||||
if field.FormulaZH != "" {
|
||||
result += " | 公式: `" + field.FormulaZH + "`"
|
||||
result += " | Formula: `" + field.FormulaZH + "`"
|
||||
}
|
||||
if field.Unit != "" {
|
||||
result += " | 单位: " + field.Unit
|
||||
result += " | Unit: " + field.Unit
|
||||
}
|
||||
result += "\n"
|
||||
return result
|
||||
|
||||
@@ -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 {
|
||||
|
||||
31
main.go
31
main.go
@@ -5,13 +5,12 @@ import (
|
||||
"nofx/auth"
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
"nofx/telemetry"
|
||||
"nofx/logger"
|
||||
"nofx/manager"
|
||||
_ "nofx/mcp/payment"
|
||||
_ "nofx/mcp/provider"
|
||||
"nofx/store"
|
||||
"nofx/telegram"
|
||||
"nofx/telemetry"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -22,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()
|
||||
|
||||
@@ -32,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")
|
||||
|
||||
@@ -130,20 +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 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)
|
||||
@@ -154,6 +154,13 @@ func main() {
|
||||
<-quit
|
||||
logger.Info("📴 Shutdown signal received, closing system...")
|
||||
|
||||
if err := server.Shutdown(); err != nil {
|
||||
logger.Warnf("⚠️ HTTP server shutdown error: %v", err)
|
||||
}
|
||||
logger.Info("✅ HTTP server stopped")
|
||||
|
||||
// nofxiAgent.Stop() is handled by defer above
|
||||
|
||||
// Stop all traders
|
||||
traderManager.StopAll()
|
||||
logger.Info("✅ System shut down safely")
|
||||
|
||||
@@ -7,10 +7,18 @@ import (
|
||||
"nofx/store"
|
||||
"nofx/trader"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func traderLogTag(traderID, traderName string) string {
|
||||
if traderName != "" {
|
||||
return fmt.Sprintf("[trader_id=%s trader_name=%s]", traderID, traderName)
|
||||
}
|
||||
return fmt.Sprintf("[trader_id=%s]", traderID)
|
||||
}
|
||||
|
||||
// CompetitionCache competition data cache
|
||||
type CompetitionCache struct {
|
||||
data map[string]interface{}
|
||||
@@ -88,9 +96,9 @@ func (tm *TraderManager) StartAll() {
|
||||
logger.Info("🚀 Starting all traders...")
|
||||
for id, t := range tm.traders {
|
||||
go func(traderID string, at *trader.AutoTrader) {
|
||||
logger.Infof("▶️ Starting %s...", at.GetName())
|
||||
logger.Infof("%s ▶️ Starting trader runtime", traderLogTag(traderID, at.GetName()))
|
||||
if err := at.Run(); err != nil {
|
||||
logger.Infof("❌ %s runtime error: %v", at.GetName(), err)
|
||||
logger.Warnf("%s runtime error: %v", traderLogTag(traderID, at.GetName()), err)
|
||||
}
|
||||
}(id, t)
|
||||
}
|
||||
@@ -136,9 +144,9 @@ func (tm *TraderManager) AutoStartRunningTraders(st *store.Store) {
|
||||
for id, t := range tm.traders {
|
||||
if runningTraderIDs[id] {
|
||||
go func(traderID string, at *trader.AutoTrader) {
|
||||
logger.Infof("▶️ Auto-restoring %s...", at.GetName())
|
||||
logger.Infof("%s ▶️ Auto-restoring trader runtime", traderLogTag(traderID, at.GetName()))
|
||||
if err := at.Run(); err != nil {
|
||||
logger.Infof("❌ %s runtime error: %v", at.GetName(), err)
|
||||
logger.Warnf("%s runtime error: %v", traderLogTag(traderID, at.GetName()), err)
|
||||
}
|
||||
}(id, t)
|
||||
startedCount++
|
||||
@@ -403,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()
|
||||
@@ -487,7 +523,7 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string
|
||||
logger.Infof("📦 Loading trader %s (AI Model: %s, Exchange: %s/%s, Strategy ID: %s)", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ExchangeType, exchangeCfg.AccountName, traderCfg.StrategyID)
|
||||
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to load trader %s: %v", traderCfg.Name, err)
|
||||
logger.Warnf("%s failed to load trader: %v", traderLogTag(traderCfg.ID, traderCfg.Name), err)
|
||||
// Save error for later retrieval
|
||||
tm.loadErrors[traderCfg.ID] = err
|
||||
} else {
|
||||
@@ -592,7 +628,7 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error {
|
||||
// Add to TraderManager (ai500APIURL/oiTopAPIURL already obtained from strategy config)
|
||||
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to add trader %s: %v", traderCfg.Name, err)
|
||||
logger.Warnf("%s failed to add trader: %v", traderLogTag(traderCfg.ID, traderCfg.Name), err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -609,25 +645,38 @@ 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)
|
||||
}
|
||||
logger.Infof("✓ Trader %s loaded strategy config: %s", traderCfg.Name, strategy.Name)
|
||||
strategyConfig.ClampLimits()
|
||||
// Sizing comes from the strategy's own RiskControl (a hardcoded
|
||||
// 6-position × equity×1.2 override used to live here, silently ignoring
|
||||
// the user's configuration). ClampLimits bounds the values and the
|
||||
// margin auto-reduce at order time keeps the book solvent.
|
||||
logger.Infof("✓ Trader %s loaded strategy config: %s (maxPos=%d, posRatio=%.1f)", traderCfg.Name, strategy.Name, strategyConfig.RiskControl.MaxPositions, strategyConfig.RiskControl.AltcoinMaxPositionValueRatio)
|
||||
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)
|
||||
@@ -645,6 +694,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",
|
||||
@@ -703,6 +753,8 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
traderConfig.CustomAPIKey = string(aiModelCfg.APIKey)
|
||||
}
|
||||
|
||||
traderConfig.Claw402WalletKey = resolveTraderDataWalletKey(st, traderCfg.UserID, aiModelCfg)
|
||||
|
||||
// Create trader instance
|
||||
at, err := trader.NewAutoTrader(traderConfig, st, traderCfg.UserID)
|
||||
if err != nil {
|
||||
@@ -725,19 +777,42 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
|
||||
// Auto-start if trader was running before shutdown
|
||||
if traderCfg.IsRunning {
|
||||
logger.Infof("🔄 Auto-starting trader '%s' (was running before shutdown)...", traderCfg.Name)
|
||||
logger.Infof("%s 🔄 Auto-starting trader (was running before shutdown)...", traderLogTag(traderCfg.ID, traderCfg.Name))
|
||||
go func(trader *trader.AutoTrader, traderName, traderID, userID string) {
|
||||
if err := trader.Run(); err != nil {
|
||||
logger.Warnf("⚠️ Trader '%s' stopped with error: %v", traderName, err)
|
||||
logger.Warnf("%s trader stopped with error: %v", traderLogTag(traderID, traderName), err)
|
||||
// Update database to reflect stopped state
|
||||
if st != nil {
|
||||
_ = st.Trader().UpdateStatus(userID, traderID, false)
|
||||
}
|
||||
}
|
||||
}(at, traderCfg.Name, traderCfg.ID, traderCfg.UserID)
|
||||
logger.Infof("✅ Trader '%s' auto-started successfully", traderCfg.Name)
|
||||
logger.Infof("%s ✅ Trader auto-started successfully", traderLogTag(traderCfg.ID, traderCfg.Name))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveTraderDataWalletKey(st *store.Store, userID string, selectedModel *store.AIModel) string {
|
||||
// Fast path: selected model is itself a claw402 model.
|
||||
if selectedModel != nil && selectedModel.Provider == "claw402" {
|
||||
if walletKey := string(selectedModel.APIKey); walletKey != "" {
|
||||
return walletKey
|
||||
}
|
||||
}
|
||||
|
||||
if st == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Fallback: find any configured claw402 model for this user so that paid
|
||||
// NofxAI data sources work even when a non-claw402 model (e.g. deepseek) is
|
||||
// selected as the AI brain.
|
||||
preferredID := ""
|
||||
walletKey, err := st.AIModel().ResolveClaw402WalletKey(userID, preferredID)
|
||||
if err != nil {
|
||||
logger.Warnf("⚠️ Failed to load claw402 wallet for trader data routing: %v", err)
|
||||
return ""
|
||||
}
|
||||
return walletKey
|
||||
}
|
||||
|
||||
480
manager/trader_manager_test.go
Normal file
480
manager/trader_manager_test.go
Normal file
@@ -0,0 +1,480 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"nofx/store"
|
||||
"nofx/trader"
|
||||
)
|
||||
|
||||
// newIdleTrader returns a zero-value AutoTrader. It is safe to store in the
|
||||
// manager map for map-semantics tests: GetStatus works on a zero value and
|
||||
// Stop returns early because the trader is not running. It must NOT be used
|
||||
// for anything that touches an exchange (Run, GetAccountInfo, ...).
|
||||
func newIdleTrader() *trader.AutoTrader {
|
||||
return &trader.AutoTrader{}
|
||||
}
|
||||
|
||||
// insertTrader places a trader directly into the manager's internal map,
|
||||
// bypassing store loading (same-package access).
|
||||
func insertTrader(tm *TraderManager, id string, t *trader.AutoTrader) {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
tm.traders[id] = t
|
||||
}
|
||||
|
||||
func TestNewTraderManager(t *testing.T) {
|
||||
tm := NewTraderManager()
|
||||
|
||||
if tm == nil {
|
||||
t.Fatal("NewTraderManager() returned nil")
|
||||
}
|
||||
if tm.traders == nil {
|
||||
t.Error("traders map should be initialized, got nil")
|
||||
}
|
||||
if len(tm.traders) != 0 {
|
||||
t.Errorf("traders map should be empty, got %d entries", len(tm.traders))
|
||||
}
|
||||
if tm.loadErrors == nil {
|
||||
t.Error("loadErrors map should be initialized, got nil")
|
||||
}
|
||||
if len(tm.loadErrors) != 0 {
|
||||
t.Errorf("loadErrors map should be empty, got %d entries", len(tm.loadErrors))
|
||||
}
|
||||
if tm.competitionCache == nil {
|
||||
t.Fatal("competitionCache should be initialized, got nil")
|
||||
}
|
||||
if tm.competitionCache.data == nil {
|
||||
t.Error("competitionCache.data should be initialized, got nil")
|
||||
}
|
||||
if !tm.competitionCache.timestamp.IsZero() {
|
||||
t.Errorf("competitionCache.timestamp should be zero, got %v", tm.competitionCache.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTrader(t *testing.T) {
|
||||
tm := NewTraderManager()
|
||||
|
||||
t.Run("missing ID returns error", func(t *testing.T) {
|
||||
got, err := tm.GetTrader("does-not-exist")
|
||||
if err == nil {
|
||||
t.Fatal("GetTrader on missing ID expected error, got nil")
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("GetTrader on missing ID should return nil trader, got %v", got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does-not-exist") {
|
||||
t.Errorf("error %q should mention the trader ID", err.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing ID returns same instance", func(t *testing.T) {
|
||||
at := newIdleTrader()
|
||||
insertTrader(tm, "trader-1", at)
|
||||
|
||||
got, err := tm.GetTrader("trader-1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTrader unexpected error: %v", err)
|
||||
}
|
||||
if got != at {
|
||||
t.Errorf("GetTrader returned %p, want the stored instance %p", got, at)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetLoadError(t *testing.T) {
|
||||
tm := NewTraderManager()
|
||||
|
||||
t.Run("unknown trader returns nil", func(t *testing.T) {
|
||||
if err := tm.GetLoadError("unknown"); err != nil {
|
||||
t.Errorf("GetLoadError for unknown trader = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stored error is returned", func(t *testing.T) {
|
||||
wantErr := errors.New("failed to create trader: boom")
|
||||
tm.mu.Lock()
|
||||
tm.loadErrors["trader-x"] = wantErr
|
||||
tm.mu.Unlock()
|
||||
|
||||
if got := tm.GetLoadError("trader-x"); !errors.Is(got, wantErr) {
|
||||
t.Errorf("GetLoadError = %v, want %v", got, wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetAllTradersReturnsCopy(t *testing.T) {
|
||||
tm := NewTraderManager()
|
||||
at1 := newIdleTrader()
|
||||
at2 := newIdleTrader()
|
||||
insertTrader(tm, "t1", at1)
|
||||
insertTrader(tm, "t2", at2)
|
||||
|
||||
all := tm.GetAllTraders()
|
||||
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("GetAllTraders returned %d entries, want 2", len(all))
|
||||
}
|
||||
if all["t1"] != at1 || all["t2"] != at2 {
|
||||
t.Error("GetAllTraders should return the same trader instances")
|
||||
}
|
||||
|
||||
// Mutating the returned map must not affect internal state.
|
||||
delete(all, "t1")
|
||||
all["t3"] = newIdleTrader()
|
||||
|
||||
if _, err := tm.GetTrader("t1"); err != nil {
|
||||
t.Errorf("deleting from returned map leaked into internal state: %v", err)
|
||||
}
|
||||
if _, err := tm.GetTrader("t3"); err == nil {
|
||||
t.Error("adding to returned map leaked into internal state")
|
||||
}
|
||||
if got := len(tm.GetTraderIDs()); got != 2 {
|
||||
t.Errorf("internal trader count = %d after mutating returned map, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTraderIDs(t *testing.T) {
|
||||
tm := NewTraderManager()
|
||||
|
||||
t.Run("empty manager returns empty non-nil slice", func(t *testing.T) {
|
||||
ids := tm.GetTraderIDs()
|
||||
if ids == nil {
|
||||
t.Fatal("GetTraderIDs should return an empty slice, got nil")
|
||||
}
|
||||
if len(ids) != 0 {
|
||||
t.Errorf("GetTraderIDs = %v, want empty", ids)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns all IDs", func(t *testing.T) {
|
||||
want := []string{"a", "b", "c"}
|
||||
for _, id := range want {
|
||||
insertTrader(tm, id, newIdleTrader())
|
||||
}
|
||||
|
||||
got := tm.GetTraderIDs()
|
||||
sort.Strings(got)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("GetTraderIDs returned %d IDs, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("GetTraderIDs[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoveTrader(t *testing.T) {
|
||||
t.Run("removes existing non-running trader", func(t *testing.T) {
|
||||
tm := NewTraderManager()
|
||||
insertTrader(tm, "t1", newIdleTrader())
|
||||
|
||||
tm.RemoveTrader("t1")
|
||||
|
||||
if _, err := tm.GetTrader("t1"); err == nil {
|
||||
t.Error("trader t1 should be removed")
|
||||
}
|
||||
if got := len(tm.GetTraderIDs()); got != 0 {
|
||||
t.Errorf("trader count after removal = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing ID is a no-op", func(t *testing.T) {
|
||||
tm := NewTraderManager()
|
||||
insertTrader(tm, "t1", newIdleTrader())
|
||||
|
||||
tm.RemoveTrader("missing") // must not panic
|
||||
|
||||
if _, err := tm.GetTrader("t1"); err != nil {
|
||||
t.Errorf("unrelated trader was removed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStartAllEmpty(t *testing.T) {
|
||||
tm := NewTraderManager()
|
||||
tm.StartAll() // must not panic with no traders
|
||||
}
|
||||
|
||||
func TestStopAllWithIdleTraders(t *testing.T) {
|
||||
tm := NewTraderManager()
|
||||
tm.StopAll() // empty: must not panic
|
||||
|
||||
insertTrader(tm, "t1", newIdleTrader())
|
||||
insertTrader(tm, "t2", newIdleTrader())
|
||||
tm.StopAll() // not-running traders: Stop is an early-return no-op
|
||||
}
|
||||
|
||||
func TestTraderLogTag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
traderID string
|
||||
traderName string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "with name",
|
||||
traderID: "abc-123",
|
||||
traderName: "MyBot",
|
||||
want: "[trader_id=abc-123 trader_name=MyBot]",
|
||||
},
|
||||
{
|
||||
name: "without name",
|
||||
traderID: "abc-123",
|
||||
want: "[trader_id=abc-123]",
|
||||
},
|
||||
{
|
||||
name: "both empty",
|
||||
want: "[trader_id=]",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := traderLogTag(tt.traderID, tt.traderName); got != tt.want {
|
||||
t.Errorf("traderLogTag(%q, %q) = %q, want %q", tt.traderID, tt.traderName, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureHyperliquidNativeStrategy(t *testing.T) {
|
||||
t.Run("nil config does not panic", func(t *testing.T) {
|
||||
ensureHyperliquidNativeStrategy("bot", "hyperliquid", nil)
|
||||
})
|
||||
|
||||
t.Run("non-hyperliquid exchange is untouched", func(t *testing.T) {
|
||||
cfg := &store.StrategyConfig{
|
||||
CoinSource: store.CoinSourceConfig{
|
||||
SourceType: "ai500",
|
||||
UseAI500: true,
|
||||
},
|
||||
}
|
||||
ensureHyperliquidNativeStrategy("bot", "binance", cfg)
|
||||
|
||||
if cfg.CoinSource.SourceType != "ai500" || !cfg.CoinSource.UseAI500 {
|
||||
t.Errorf("non-hyperliquid config was modified: %+v", cfg.CoinSource)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("native sources are kept as-is", func(t *testing.T) {
|
||||
nativeSources := []string{"hyper_rank", "vergex_signal", "static", "hyper_all", "hyper_main", " Hyper_Rank "}
|
||||
for _, src := range nativeSources {
|
||||
cfg := &store.StrategyConfig{
|
||||
CoinSource: store.CoinSourceConfig{SourceType: src},
|
||||
}
|
||||
ensureHyperliquidNativeStrategy("bot", "hyperliquid", cfg)
|
||||
|
||||
if cfg.CoinSource.SourceType != src {
|
||||
t.Errorf("native source %q was rewritten to %q", src, cfg.CoinSource.SourceType)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("legacy source on hyperliquid is forced to hyper_rank with defaults", func(t *testing.T) {
|
||||
cfg := &store.StrategyConfig{
|
||||
CoinSource: store.CoinSourceConfig{
|
||||
SourceType: "ai500",
|
||||
UseAI500: true,
|
||||
UseOITop: true,
|
||||
UseOILow: true,
|
||||
UseHyperAll: true,
|
||||
UseHyperMain: true,
|
||||
},
|
||||
}
|
||||
ensureHyperliquidNativeStrategy("bot", "hyperliquid", cfg)
|
||||
|
||||
cs := cfg.CoinSource
|
||||
if cs.SourceType != "hyper_rank" {
|
||||
t.Errorf("SourceType = %q, want hyper_rank", cs.SourceType)
|
||||
}
|
||||
if cs.UseAI500 || cs.UseOITop || cs.UseOILow || cs.UseHyperAll || cs.UseHyperMain {
|
||||
t.Errorf("legacy source flags should all be cleared: %+v", cs)
|
||||
}
|
||||
if cs.HyperRankCategory != "stock" {
|
||||
t.Errorf("HyperRankCategory = %q, want stock", cs.HyperRankCategory)
|
||||
}
|
||||
if cs.HyperRankDirection != "gainers" {
|
||||
t.Errorf("HyperRankDirection = %q, want gainers", cs.HyperRankDirection)
|
||||
}
|
||||
if cs.HyperRankLimit != 5 {
|
||||
t.Errorf("HyperRankLimit = %d, want 5", cs.HyperRankLimit)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing hyper_rank settings are preserved when forcing", func(t *testing.T) {
|
||||
cfg := &store.StrategyConfig{
|
||||
CoinSource: store.CoinSourceConfig{
|
||||
SourceType: "oi_top",
|
||||
HyperRankCategory: "crypto",
|
||||
HyperRankDirection: "losers",
|
||||
HyperRankLimit: 8,
|
||||
},
|
||||
}
|
||||
ensureHyperliquidNativeStrategy("bot", "hyperliquid", cfg)
|
||||
|
||||
cs := cfg.CoinSource
|
||||
if cs.SourceType != "hyper_rank" {
|
||||
t.Errorf("SourceType = %q, want hyper_rank", cs.SourceType)
|
||||
}
|
||||
if cs.HyperRankCategory != "crypto" {
|
||||
t.Errorf("HyperRankCategory = %q, want crypto (preserved)", cs.HyperRankCategory)
|
||||
}
|
||||
if cs.HyperRankDirection != "losers" {
|
||||
t.Errorf("HyperRankDirection = %q, want losers (preserved)", cs.HyperRankDirection)
|
||||
}
|
||||
if cs.HyperRankLimit != 8 {
|
||||
t.Errorf("HyperRankLimit = %d, want 8 (preserved)", cs.HyperRankLimit)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exchange type is matched case-insensitively with whitespace", func(t *testing.T) {
|
||||
cfg := &store.StrategyConfig{
|
||||
CoinSource: store.CoinSourceConfig{SourceType: "ai500"},
|
||||
}
|
||||
ensureHyperliquidNativeStrategy("bot", " HyperLiquid ", cfg)
|
||||
|
||||
if cfg.CoinSource.SourceType != "hyper_rank" {
|
||||
t.Errorf("SourceType = %q, want hyper_rank for case-insensitive exchange match", cfg.CoinSource.SourceType)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetCompetitionDataEmptyAndCache(t *testing.T) {
|
||||
tm := NewTraderManager()
|
||||
|
||||
first, err := tm.GetCompetitionData()
|
||||
if err != nil {
|
||||
t.Fatalf("GetCompetitionData unexpected error: %v", err)
|
||||
}
|
||||
if got := first["count"]; got != 0 {
|
||||
t.Errorf("count = %v, want 0", got)
|
||||
}
|
||||
if got := first["total_count"]; got != 0 {
|
||||
t.Errorf("total_count = %v, want 0", got)
|
||||
}
|
||||
|
||||
tm.competitionCache.mu.RLock()
|
||||
cachedTimestamp := tm.competitionCache.timestamp
|
||||
tm.competitionCache.mu.RUnlock()
|
||||
if cachedTimestamp.IsZero() {
|
||||
t.Error("competition cache timestamp should be set after first call")
|
||||
}
|
||||
|
||||
// Second call within 30s must be served from the cache.
|
||||
second, err := tm.GetCompetitionData()
|
||||
if err != nil {
|
||||
t.Fatalf("GetCompetitionData (cached) unexpected error: %v", err)
|
||||
}
|
||||
if got := second["count"]; got != 0 {
|
||||
t.Errorf("cached count = %v, want 0", got)
|
||||
}
|
||||
|
||||
tm.competitionCache.mu.RLock()
|
||||
timestampAfterSecond := tm.competitionCache.timestamp
|
||||
tm.competitionCache.mu.RUnlock()
|
||||
if !timestampAfterSecond.Equal(cachedTimestamp) {
|
||||
t.Error("cached call should not refresh the cache timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTopTradersDataEmpty(t *testing.T) {
|
||||
tm := NewTraderManager()
|
||||
|
||||
result, err := tm.GetTopTradersData()
|
||||
if err != nil {
|
||||
t.Fatalf("GetTopTradersData unexpected error: %v", err)
|
||||
}
|
||||
if got := result["count"]; got != 0 {
|
||||
t.Errorf("count = %v, want 0", got)
|
||||
}
|
||||
traders, ok := result["traders"].([]map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("traders has type %T, want []map[string]interface{}", result["traders"])
|
||||
}
|
||||
if len(traders) != 0 {
|
||||
t.Errorf("traders length = %d, want 0", len(traders))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetComparisonDataEmpty(t *testing.T) {
|
||||
tm := NewTraderManager()
|
||||
|
||||
result, err := tm.GetComparisonData()
|
||||
if err != nil {
|
||||
t.Fatalf("GetComparisonData unexpected error: %v", err)
|
||||
}
|
||||
if got := result["count"]; got != 0 {
|
||||
t.Errorf("count = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentAccess exercises the RWMutex by hammering the read paths
|
||||
// while traders are removed concurrently. Run with -race.
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
tm := NewTraderManager()
|
||||
|
||||
const traderCount = 16
|
||||
ids := make([]string, 0, traderCount)
|
||||
for i := 0; i < traderCount; i++ {
|
||||
id := fmt.Sprintf("trader-%d", i)
|
||||
ids = append(ids, id)
|
||||
insertTrader(tm, id, newIdleTrader())
|
||||
}
|
||||
|
||||
const (
|
||||
goroutinesPerKind = 8
|
||||
iterations = 200
|
||||
)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Readers: GetTrader / GetLoadError
|
||||
for g := 0; g < goroutinesPerKind; g++ {
|
||||
wg.Add(1)
|
||||
go func(seed int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
id := ids[(seed+i)%traderCount]
|
||||
_, _ = tm.GetTrader(id)
|
||||
_ = tm.GetLoadError(id)
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
// Readers: GetAllTraders / GetTraderIDs
|
||||
for g := 0; g < goroutinesPerKind; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
_ = tm.GetAllTraders()
|
||||
_ = tm.GetTraderIDs()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Writers: RemoveTrader (including repeated removal of the same ID)
|
||||
for g := 0; g < goroutinesPerKind; g++ {
|
||||
wg.Add(1)
|
||||
go func(seed int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
tm.RemoveTrader(ids[(seed+i)%traderCount])
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if got := len(tm.GetTraderIDs()); got != 0 {
|
||||
t.Errorf("all traders should be removed after concurrent removal, %d left", got)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"nofx/logger"
|
||||
"nofx/provider/hyperliquid"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -540,6 +541,9 @@ var xyzDexAssets = map[string]bool{
|
||||
// IsXyzDexAsset checks if a symbol is an xyz dex asset
|
||||
func IsXyzDexAsset(symbol string) bool {
|
||||
base := strings.ToUpper(symbol)
|
||||
if strings.HasSuffix(base, "-USDC") || strings.HasPrefix(strings.ToLower(base), "xyz:") {
|
||||
return hyperliquid.IsXYZAsset(base)
|
||||
}
|
||||
// Remove any prefix/suffix
|
||||
base = strings.TrimPrefix(base, "XYZ:")
|
||||
for _, suffix := range []string{"USDT", "USD", "-USDC"} {
|
||||
@@ -548,7 +552,7 @@ func IsXyzDexAsset(symbol string) bool {
|
||||
break
|
||||
}
|
||||
}
|
||||
return xyzDexAssets[base]
|
||||
return xyzDexAssets[base] || hyperliquid.IsXYZAsset(base)
|
||||
}
|
||||
|
||||
// Normalize normalizes symbol
|
||||
@@ -556,22 +560,13 @@ func IsXyzDexAsset(symbol string) bool {
|
||||
// For xyz dex assets (stocks, forex, commodities): uses xyz: prefix without USDT suffix
|
||||
func Normalize(symbol string) string {
|
||||
symbol = strings.ToUpper(symbol)
|
||||
if strings.HasSuffix(symbol, "-USDC") {
|
||||
return hyperliquid.FormatCoinForAPI(symbol)
|
||||
}
|
||||
|
||||
// Check if this is an xyz dex asset
|
||||
if IsXyzDexAsset(symbol) {
|
||||
// Remove any xyz: prefix (case-insensitive) and USDT suffix, then add xyz: prefix
|
||||
base := symbol
|
||||
// Handle both lowercase and uppercase xyz: prefix
|
||||
if strings.HasPrefix(strings.ToLower(base), "xyz:") {
|
||||
base = base[4:] // Remove first 4 characters ("xyz:")
|
||||
}
|
||||
for _, suffix := range []string{"USDT", "USD", "-USDC"} {
|
||||
if strings.HasSuffix(base, suffix) {
|
||||
base = strings.TrimSuffix(base, suffix)
|
||||
break
|
||||
}
|
||||
}
|
||||
return "xyz:" + base
|
||||
return hyperliquid.FormatCoinForAPI(symbol)
|
||||
}
|
||||
|
||||
// Remove exchange-specific separators (Gate uses BTC_USDT, OKX uses BTC-USDT-SWAP)
|
||||
|
||||
26
market/data_hyperliquid_xyz_test.go
Normal file
26
market/data_hyperliquid_xyz_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package market
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHyperliquidXYZAliasesNormalizeForAIDecisionData(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
normalized string
|
||||
isXyzAsset bool
|
||||
}{
|
||||
{input: "SMSN-USDC", normalized: "xyz:SMSN", isXyzAsset: true},
|
||||
{input: "SAMSUNG-USDC", normalized: "xyz:SMSN", isXyzAsset: true},
|
||||
{input: "xyz:SMSN", normalized: "xyz:SMSN", isXyzAsset: true},
|
||||
{input: "TESLA-USDC", normalized: "xyz:TSLA", isXyzAsset: true},
|
||||
{input: "TSLA-USDC", normalized: "xyz:TSLA", isXyzAsset: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := Normalize(tt.input); got != tt.normalized {
|
||||
t.Fatalf("Normalize(%q) = %q, want %q", tt.input, got, tt.normalized)
|
||||
}
|
||||
if got := IsXyzDexAsset(tt.normalized); got != tt.isXyzAsset {
|
||||
t.Fatalf("IsXyzDexAsset(%q) = %v, want %v", tt.normalized, got, tt.isXyzAsset)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,18 +114,17 @@ func getKlinesFromCoinAnk(symbol, interval, exchange string, limit int) ([]Kline
|
||||
|
||||
// getKlinesFromHyperliquid fetches kline data from Hyperliquid API for xyz dex assets
|
||||
func getKlinesFromHyperliquid(symbol, interval string, limit int) ([]Kline, error) {
|
||||
// Remove xyz: prefix if present for the API call
|
||||
baseCoin := strings.TrimPrefix(symbol, "xyz:")
|
||||
|
||||
// Map interval to Hyperliquid format
|
||||
// Pass the symbol AS-IS to GetCandles. It internally calls FormatCoinForAPI
|
||||
// which handles the xyz: prefix correctly. Stripping the prefix here was a
|
||||
// bug: if the base symbol (e.g. "QNT") was not in our hardcoded
|
||||
// StockPerpsSymbols list, FormatCoinForAPI couldn't tell it was an xyz
|
||||
// asset and the request hit the crypto perp endpoint instead — which
|
||||
// returns 500 for stock symbols that have no crypto perp on Hyperliquid.
|
||||
hlInterval := hyperliquid.MapTimeframe(interval)
|
||||
|
||||
// Create Hyperliquid client
|
||||
client := hyperliquid.NewClient()
|
||||
|
||||
// Fetch candles
|
||||
ctx := context.Background()
|
||||
candles, err := client.GetCandles(ctx, baseCoin, hlInterval, limit)
|
||||
candles, err := client.GetCandles(ctx, symbol, hlInterval, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Hyperliquid API error: %w", err)
|
||||
}
|
||||
|
||||
@@ -43,15 +43,6 @@ func TFDuration(tf string) (time.Duration, error) {
|
||||
return supportedTimeframes[norm], nil
|
||||
}
|
||||
|
||||
// MustNormalizeTimeframe is similar to NormalizeTimeframe, but panics when unsupported.
|
||||
func MustNormalizeTimeframe(tf string) string {
|
||||
norm, err := NormalizeTimeframe(tf)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return norm
|
||||
}
|
||||
|
||||
// SupportedTimeframes returns all supported timeframes (sorted slice).
|
||||
func SupportedTimeframes() []string {
|
||||
keys := make([]string, 0, len(supportedTimeframes))
|
||||
|
||||
123
market/timeframe_test.go
Normal file
123
market/timeframe_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package market
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeTimeframe(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid lowercase minute", input: "1m", want: "1m"},
|
||||
{name: "valid lowercase hour", input: "4h", want: "4h"},
|
||||
{name: "valid lowercase day", input: "1d", want: "1d"},
|
||||
{name: "uppercase normalized", input: "1H", want: "1h"},
|
||||
{name: "mixed case normalized", input: "15M", want: "15m"},
|
||||
{name: "uppercase day", input: "1D", want: "1d"},
|
||||
{name: "leading and trailing whitespace", input: " 30m ", want: "30m"},
|
||||
{name: "whitespace and uppercase", input: " 12H ", want: "12h"},
|
||||
{name: "empty string", input: "", wantErr: true},
|
||||
{name: "whitespace only", input: " ", wantErr: true},
|
||||
{name: "unsupported value", input: "7m", wantErr: true},
|
||||
{name: "unsupported week", input: "1w", wantErr: true},
|
||||
{name: "garbage input", input: "abc", wantErr: true},
|
||||
{name: "internal whitespace not trimmed", input: "1 m", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NormalizeTimeframe(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("NormalizeTimeframe(%q) = %q, want error", tt.input, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeTimeframe(%q) unexpected error: %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeTimeframe(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTFDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "one minute", input: "1m", want: time.Minute},
|
||||
{name: "three minutes", input: "3m", want: 3 * time.Minute},
|
||||
{name: "five minutes", input: "5m", want: 5 * time.Minute},
|
||||
{name: "fifteen minutes", input: "15m", want: 15 * time.Minute},
|
||||
{name: "thirty minutes", input: "30m", want: 30 * time.Minute},
|
||||
{name: "one hour", input: "1h", want: time.Hour},
|
||||
{name: "two hours", input: "2h", want: 2 * time.Hour},
|
||||
{name: "four hours", input: "4h", want: 4 * time.Hour},
|
||||
{name: "six hours", input: "6h", want: 6 * time.Hour},
|
||||
{name: "twelve hours", input: "12h", want: 12 * time.Hour},
|
||||
{name: "one day", input: "1d", want: 24 * time.Hour},
|
||||
{name: "uppercase with whitespace", input: " 1D ", want: 24 * time.Hour},
|
||||
{name: "empty string", input: "", wantErr: true},
|
||||
{name: "unsupported value", input: "2d", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := TFDuration(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("TFDuration(%q) = %v, want error", tt.input, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("TFDuration(%q) unexpected error: %v", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("TFDuration(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedTimeframes(t *testing.T) {
|
||||
got := SupportedTimeframes()
|
||||
|
||||
if len(got) == 0 {
|
||||
t.Fatal("SupportedTimeframes() returned empty slice")
|
||||
}
|
||||
|
||||
if !slices.IsSorted(got) {
|
||||
t.Errorf("SupportedTimeframes() not sorted: %v", got)
|
||||
}
|
||||
|
||||
for _, required := range []string{"1m", "1d"} {
|
||||
if !slices.Contains(got, required) {
|
||||
t.Errorf("SupportedTimeframes() missing %q: %v", required, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Every advertised timeframe must round-trip through NormalizeTimeframe and TFDuration.
|
||||
for _, tf := range got {
|
||||
norm, err := NormalizeTimeframe(tf)
|
||||
if err != nil {
|
||||
t.Errorf("NormalizeTimeframe(%q) unexpected error: %v", tf, err)
|
||||
}
|
||||
if norm != tf {
|
||||
t.Errorf("NormalizeTimeframe(%q) = %q, want identity", tf, norm)
|
||||
}
|
||||
if d, err := TFDuration(tf); err != nil || d <= 0 {
|
||||
t.Errorf("TFDuration(%q) = %v, %v; want positive duration and nil error", tf, d, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ var (
|
||||
"no such host",
|
||||
"stream error", // HTTP/2 stream error
|
||||
"INTERNAL_ERROR", // Server internal error
|
||||
"status 429", // Rate limit / upstream gateway throttling
|
||||
"rate_limit_error",
|
||||
"upstream_empty_output",
|
||||
"status 502", // Bad Gateway
|
||||
"status 503", // Service Unavailable
|
||||
"status 520", // Cloudflare origin error
|
||||
@@ -197,7 +200,9 @@ func (client *Client) CallWithMessages(systemPrompt, userPrompt string) (string,
|
||||
if attempt < maxRetries {
|
||||
waitTime := client.Cfg.RetryWaitBase * time.Duration(attempt)
|
||||
client.Log.Infof("⏳ Waiting %v before retry...", waitTime)
|
||||
time.Sleep(waitTime)
|
||||
if err := sleepWithContext(context.Background(), waitTime); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,6 +279,7 @@ func (client *Client) ParseMCPResponseFull(body []byte) (*LLMResponse, error) {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
@@ -306,6 +312,7 @@ func (client *Client) ParseMCPResponseFull(body []byte) (*LLMResponse, error) {
|
||||
msg := result.Choices[0].Message
|
||||
return &LLMResponse{
|
||||
Content: msg.Content,
|
||||
ReasoningContent: msg.ReasoningContent,
|
||||
ToolCalls: msg.ToolCalls,
|
||||
}, nil
|
||||
}
|
||||
@@ -332,6 +339,38 @@ func (client *Client) BuildRequest(url string, jsonData []byte) (*http.Request,
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func contextFromRequest(req *Request) context.Context {
|
||||
if req != nil && req.Ctx != nil {
|
||||
return req.Ctx
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
func (client *Client) buildHTTPRequestWithContext(ctx context.Context, url string, jsonData []byte) (*http.Request, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
httpReq, err := client.Hooks.BuildRequest(url, jsonData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return httpReq.WithContext(ctx), nil
|
||||
}
|
||||
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Call single AI API call (fixed flow, cannot be overridden)
|
||||
func (client *Client) Call(systemPrompt, userPrompt string) (string, error) {
|
||||
// Print current AI configuration
|
||||
@@ -450,7 +489,9 @@ func (client *Client) CallWithRequest(req *Request) (string, error) {
|
||||
if attempt < maxRetries {
|
||||
waitTime := client.Cfg.RetryWaitBase * time.Duration(attempt)
|
||||
client.Log.Infof("⏳ Waiting %v before retry...", waitTime)
|
||||
time.Sleep(waitTime)
|
||||
if err := sleepWithContext(contextFromRequest(req), waitTime); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,7 +523,9 @@ func (client *Client) CallWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||
}
|
||||
if attempt < maxRetries {
|
||||
waitTime := client.Cfg.RetryWaitBase * time.Duration(attempt)
|
||||
time.Sleep(waitTime)
|
||||
if err := sleepWithContext(contextFromRequest(req), waitTime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("still failed after %d retries: %w", maxRetries, lastErr)
|
||||
@@ -499,7 +542,7 @@ func (client *Client) callWithRequestFull(req *Request) (*LLMResponse, error) {
|
||||
}
|
||||
|
||||
url := client.Hooks.BuildUrl()
|
||||
httpReq, err := client.Hooks.BuildRequest(url, jsonData)
|
||||
httpReq, err := client.buildHTTPRequestWithContext(contextFromRequest(req), url, jsonData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -537,7 +580,7 @@ func (client *Client) callWithRequest(req *Request) (string, error) {
|
||||
url := client.Hooks.BuildUrl()
|
||||
client.Log.Infof("📡 [MCP %s] Request URL: %s", client.String(), url)
|
||||
|
||||
httpReq, err := client.Hooks.BuildRequest(url, jsonData)
|
||||
httpReq, err := client.buildHTTPRequestWithContext(contextFromRequest(req), url, jsonData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -583,6 +626,11 @@ func (client *Client) BuildRequestBodyFromRequest(req *Request) map[string]any {
|
||||
} else {
|
||||
m["content"] = msg.Content
|
||||
}
|
||||
// DeepSeek thinking models require reasoning_content to be echoed back
|
||||
// in multi-turn conversations when present in assistant messages.
|
||||
if msg.ReasoningContent != "" {
|
||||
m["reasoning_content"] = msg.ReasoningContent
|
||||
}
|
||||
messages = append(messages, m)
|
||||
}
|
||||
|
||||
@@ -679,7 +727,7 @@ func (client *Client) CallWithRequestStream(req *Request, onChunk func(string))
|
||||
}
|
||||
|
||||
url := client.Hooks.BuildUrl()
|
||||
httpReq, err := client.Hooks.BuildRequest(url, jsonData)
|
||||
httpReq, err := client.buildHTTPRequestWithContext(contextFromRequest(req), url, jsonData)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -687,7 +735,7 @@ func (client *Client) CallWithRequestStream(req *Request, onChunk func(string))
|
||||
// Idle-timeout watchdog: cancel the request if no SSE line arrives for 60 seconds.
|
||||
// This breaks the scanner out of an indefinitely blocking Read on a hung connection.
|
||||
const idleTimeout = 60 * time.Second
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ctx, cancel := context.WithCancel(contextFromRequest(req))
|
||||
defer cancel()
|
||||
resetCh := make(chan struct{}, 1)
|
||||
go func() {
|
||||
@@ -725,21 +773,24 @@ func (client *Client) CallWithRequestStream(req *Request, onChunk func(string))
|
||||
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return ParseSSEStream(resp.Body, onChunk, func() {
|
||||
text, usage, err := ParseSSEStream(resp.Body, onChunk, func() {
|
||||
select {
|
||||
case resetCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
ReportStreamUsage(usage, client.Provider, client.Model)
|
||||
return text, err
|
||||
}
|
||||
|
||||
// ParseSSEStream reads an SSE response body, accumulates text deltas,
|
||||
// and calls onChunk with the full accumulated text after each chunk.
|
||||
// If onLine is non-nil, it is called after each raw SSE line is scanned
|
||||
// (useful for resetting idle-timeout watchdogs).
|
||||
// Returns the complete accumulated text.
|
||||
func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string, error) {
|
||||
// Returns the complete accumulated text and any parsed token usage (nil if absent).
|
||||
func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string, *TokenUsage, error) {
|
||||
var accumulated strings.Builder
|
||||
var usage *TokenUsage
|
||||
scanner := bufio.NewScanner(body)
|
||||
|
||||
for scanner.Scan() {
|
||||
@@ -774,8 +825,11 @@ func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string
|
||||
}
|
||||
|
||||
if chunk.Usage != nil && chunk.Usage.TotalTokens > 0 {
|
||||
fmt.Printf("📊 [TokenUsage] prompt=%d, completion=%d, total=%d\n",
|
||||
chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens, chunk.Usage.TotalTokens)
|
||||
usage = &TokenUsage{
|
||||
PromptTokens: chunk.Usage.PromptTokens,
|
||||
CompletionTokens: chunk.Usage.CompletionTokens,
|
||||
TotalTokens: chunk.Usage.TotalTokens,
|
||||
}
|
||||
}
|
||||
|
||||
if len(chunk.Choices) == 0 {
|
||||
@@ -794,8 +848,23 @@ func ParseSSEStream(body io.Reader, onChunk func(string), onLine func()) (string
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return accumulated.String(), fmt.Errorf("stream interrupted: %w", err)
|
||||
return accumulated.String(), usage, fmt.Errorf("stream interrupted: %w", err)
|
||||
}
|
||||
|
||||
return accumulated.String(), nil
|
||||
return accumulated.String(), usage, nil
|
||||
}
|
||||
|
||||
// ReportStreamUsage fires TokenUsageCallback with the given usage, provider, and model.
|
||||
// No-op if usage is nil or callback is unset.
|
||||
func ReportStreamUsage(usage *TokenUsage, provider, model string) {
|
||||
if usage == nil || TokenUsageCallback == nil || usage.TotalTokens <= 0 {
|
||||
return
|
||||
}
|
||||
TokenUsageCallback(TokenUsage{
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
PromptTokens: usage.PromptTokens,
|
||||
CompletionTokens: usage.CompletionTokens,
|
||||
TotalTokens: usage.TotalTokens,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -345,6 +345,11 @@ func TestClient_IsRetryableError(t *testing.T) {
|
||||
err: errors.New("connection reset by peer"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "upstream empty output",
|
||||
err: errors.New(`API returned error (status 429): {"error":{"code":"upstream_empty_output","message":"Upstream model returned empty output.","type":"rate_limit_error"}}`),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "normal error",
|
||||
err: errors.New("bad request"),
|
||||
|
||||
@@ -2,6 +2,7 @@ package payment
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -9,11 +10,47 @@ import (
|
||||
|
||||
"nofx/mcp"
|
||||
"nofx/mcp/provider"
|
||||
"nofx/store"
|
||||
"nofx/wallet"
|
||||
)
|
||||
|
||||
// Per-call cost buffers for preflight. Reasoner models emit long chain-of-thought
|
||||
// tokens whose cost can far exceed the flat per-call estimate in store.GetModelPrice,
|
||||
// so they use a larger multiplier.
|
||||
const (
|
||||
preflightSafetyMultiplier = 1.5
|
||||
preflightReasonerSafetyMultiplier = 4.0
|
||||
)
|
||||
|
||||
// ErrInsufficientFunds is returned when the claw402 wallet does not hold
|
||||
// enough USDC to cover the estimated cost of a call. Callers can type-assert
|
||||
// to surface balance/needed/address to the UI.
|
||||
type ErrInsufficientFunds struct {
|
||||
Address string
|
||||
Balance float64
|
||||
Needed float64
|
||||
Model string
|
||||
}
|
||||
|
||||
func (e *ErrInsufficientFunds) Error() string {
|
||||
return fmt.Sprintf(
|
||||
"claw402 insufficient USDC: wallet=%s balance=$%.4f needed=$%.4f model=%s",
|
||||
shortAddr(e.Address), e.Balance, e.Needed, e.Model,
|
||||
)
|
||||
}
|
||||
|
||||
// shortAddr renders 0x1234…abcd for log/error strings that may leak into
|
||||
// telemetry bundles. The full address stays on the struct for programmatic use.
|
||||
func shortAddr(addr string) string {
|
||||
if len(addr) < 10 {
|
||||
return addr
|
||||
}
|
||||
return addr[:6] + "…" + addr[len(addr)-4:]
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultClaw402URL = "https://claw402.ai"
|
||||
DefaultClaw402Model = "glm-5"
|
||||
DefaultClaw402Model = "deepseek-v4-flash"
|
||||
)
|
||||
|
||||
// claw402ModelEndpoints maps user-friendly model names to claw402 API paths.
|
||||
@@ -28,6 +65,8 @@ var claw402ModelEndpoints = map[string]string{
|
||||
// DeepSeek
|
||||
"deepseek": "/api/v1/ai/deepseek/chat",
|
||||
"deepseek-reasoner": "/api/v1/ai/deepseek/chat/reasoner",
|
||||
"deepseek-v4-flash": "/api/v1/ai/deepseek/v4-flash",
|
||||
"deepseek-v4-pro": "/api/v1/ai/deepseek/v4-pro",
|
||||
// Qwen
|
||||
"qwen-max": "/api/v1/ai/qwen/chat/max",
|
||||
"qwen-plus": "/api/v1/ai/qwen/chat/plus",
|
||||
@@ -39,7 +78,7 @@ var claw402ModelEndpoints = map[string]string{
|
||||
"gemini-3.1-pro": "/api/v1/ai/gemini/chat/3.1-pro",
|
||||
// Kimi
|
||||
"kimi-k2.5": "/api/v1/ai/kimi/chat/k2.5",
|
||||
// Z.AI (智谱)
|
||||
// Z.AI (Zhipu)
|
||||
"glm-5": "/api/v1/ai/zhipu/chat",
|
||||
"glm-5-turbo": "/api/v1/ai/zhipu/chat/turbo",
|
||||
}
|
||||
@@ -128,13 +167,57 @@ func (c *Claw402Client) resolveEndpoint() string {
|
||||
func (c *Claw402Client) SetAuthHeader(h http.Header) { X402SetAuthHeader(h) }
|
||||
|
||||
func (c *Claw402Client) Call(systemPrompt, userPrompt string) (string, error) {
|
||||
if err := c.preflightBalance(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return X402CallStream(c.Client, c.signPayment, "Claw402", systemPrompt, userPrompt, nil)
|
||||
}
|
||||
|
||||
func (c *Claw402Client) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
|
||||
if err := c.preflightBalance(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return X402CallFull(c.Client, c.signPayment, "Claw402", req)
|
||||
}
|
||||
|
||||
// walletAddress derives the EVM address from the configured private key.
|
||||
// Returns "" when no key has been set (client unconfigured).
|
||||
func (c *Claw402Client) walletAddress() string {
|
||||
if c.privateKey == nil {
|
||||
return ""
|
||||
}
|
||||
return crypto.PubkeyToAddress(c.privateKey.PublicKey).Hex()
|
||||
}
|
||||
|
||||
// preflightBalance short-circuits a call when the wallet cannot cover the
|
||||
// estimated cost. RPC failures fall through — x402 will still reject an
|
||||
// actually-empty wallet, so we prefer availability over extra strictness.
|
||||
func (c *Claw402Client) preflightBalance() error {
|
||||
addr := c.walletAddress()
|
||||
if addr == "" {
|
||||
return nil
|
||||
}
|
||||
balance, err := wallet.QueryUSDCBalanceCached(addr)
|
||||
if err != nil {
|
||||
c.Log.Warnf("⚠️ [MCP] Claw402 balance preflight skipped (RPC error): %v", err)
|
||||
return nil
|
||||
}
|
||||
multiplier := preflightSafetyMultiplier
|
||||
if strings.Contains(strings.ToLower(c.Model), "reasoner") {
|
||||
multiplier = preflightReasonerSafetyMultiplier
|
||||
}
|
||||
needed := store.GetModelPrice(c.Model) * multiplier
|
||||
if balance < needed {
|
||||
return &ErrInsufficientFunds{
|
||||
Address: addr,
|
||||
Balance: balance,
|
||||
Needed: needed,
|
||||
Model: c.Model,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// signPayment signs x402 v2 EIP-712 payment on Base chain + USDC.
|
||||
func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) {
|
||||
return SignBasePaymentHeader(c.privateKey, paymentHeaderB64, "Claw402")
|
||||
@@ -142,18 +225,34 @@ func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) {
|
||||
|
||||
// ── Format overrides for Anthropic endpoints ─────────────────────────────────
|
||||
|
||||
// stripMaxTokens removes per-call max_tokens caps from a body destined for
|
||||
// claw402. The gateway already enforces a per-route default/floor/cap
|
||||
// (see providers/*.yaml token_default_max_out / token_min_max_out /
|
||||
// token_max_out_cap). Sending a small max_tokens here on a thinking model
|
||||
// (Kimi K2.5, DeepSeek R1/V4) caused reasoning tokens to consume the entire
|
||||
// budget and left `delta.content` empty, surfacing as "no content received".
|
||||
// upto settles on real usage, so removing the cap costs nothing extra.
|
||||
func stripMaxTokens(body map[string]any) map[string]any {
|
||||
if body == nil {
|
||||
return body
|
||||
}
|
||||
delete(body, "max_tokens")
|
||||
delete(body, "max_completion_tokens")
|
||||
return body
|
||||
}
|
||||
|
||||
func (c *Claw402Client) BuildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
|
||||
if c.claudeProxy != nil {
|
||||
return c.claudeProxy.BuildMCPRequestBody(systemPrompt, userPrompt)
|
||||
}
|
||||
return c.Client.BuildMCPRequestBody(systemPrompt, userPrompt)
|
||||
return stripMaxTokens(c.Client.BuildMCPRequestBody(systemPrompt, userPrompt))
|
||||
}
|
||||
|
||||
func (c *Claw402Client) BuildRequestBodyFromRequest(req *mcp.Request) map[string]any {
|
||||
if c.claudeProxy != nil {
|
||||
return c.claudeProxy.BuildRequestBodyFromRequest(req)
|
||||
}
|
||||
return c.Client.BuildRequestBodyFromRequest(req)
|
||||
return stripMaxTokens(c.Client.BuildRequestBodyFromRequest(req))
|
||||
}
|
||||
|
||||
func (c *Claw402Client) ParseMCPResponse(body []byte) (string, error) {
|
||||
|
||||
@@ -35,6 +35,95 @@ const (
|
||||
X402Timeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
func x402ContextFromRequest(req *mcp.Request) context.Context {
|
||||
if req != nil && req.Ctx != nil {
|
||||
return req.Ctx
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
func x402Sleep(ctx context.Context, d time.Duration) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func doInitialX402Request(
|
||||
ctx context.Context,
|
||||
httpClient *http.Client,
|
||||
buildReqFn func() (*http.Request, error),
|
||||
providerTag string,
|
||||
logger mcp.Logger,
|
||||
) (*http.Response, error) {
|
||||
var lastBody []byte
|
||||
var lastErr error
|
||||
var lastStatus int
|
||||
|
||||
for attempt := 1; attempt <= X402MaxPaymentRetries; attempt++ {
|
||||
req, err := buildReqFn()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if attempt < X402MaxPaymentRetries {
|
||||
wait := X402RetryBaseWait * time.Duration(attempt)
|
||||
logger.Warnf("⚠️ [%s] Initial request failed: %v, retrying in %v (%d/%d)...",
|
||||
providerTag, err, wait, attempt+1, X402MaxPaymentRetries)
|
||||
if err := x402Sleep(ctx, wait); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPaymentRequired {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", readErr)
|
||||
}
|
||||
lastBody = body
|
||||
lastStatus = resp.StatusCode
|
||||
|
||||
if isRetryableInitialX402Status(resp.StatusCode) && attempt < X402MaxPaymentRetries {
|
||||
wait := X402RetryBaseWait * time.Duration(attempt)
|
||||
logger.Warnf("⚠️ [%s] Initial server error (status %d), retrying in %v (%d/%d)...",
|
||||
providerTag, resp.StatusCode, wait, attempt+1, X402MaxPaymentRetries)
|
||||
if err := x402Sleep(ctx, wait); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%s API error (status %d): %s", providerTag, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("failed to send request after %d retries: %w", X402MaxPaymentRetries, lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("%s API error after %d retries (status %d): %s", providerTag, X402MaxPaymentRetries, lastStatus, string(lastBody))
|
||||
}
|
||||
|
||||
func isRetryableInitialX402Status(status int) bool {
|
||||
return status == http.StatusTooManyRequests || status >= 500
|
||||
}
|
||||
|
||||
// ── Shared x402 types ────────────────────────────────────────────────────────
|
||||
|
||||
// X402v2PaymentRequired is the structure of the Payment-Required header (x402 v2).
|
||||
@@ -114,20 +203,20 @@ func SignBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string
|
||||
|
||||
// DoX402Request executes an HTTP request and handles the x402 v2 payment flow.
|
||||
func DoX402Request(
|
||||
ctx context.Context,
|
||||
httpClient *http.Client,
|
||||
buildReqFn func() (*http.Request, error),
|
||||
signFn X402SignFunc,
|
||||
providerTag string,
|
||||
logger mcp.Logger,
|
||||
) ([]byte, error) {
|
||||
req, err := buildReqFn()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
resp, err := doInitialX402Request(ctx, httpClient, buildReqFn, providerTag, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -157,6 +246,7 @@ func DoX402Request(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build retry request: %w", err)
|
||||
}
|
||||
req2 = req2.WithContext(ctx)
|
||||
req2.Header.Set("X-Payment", paymentSig)
|
||||
req2.Header.Set("Payment-Signature", paymentSig)
|
||||
|
||||
@@ -166,7 +256,9 @@ func DoX402Request(
|
||||
wait := X402RetryBaseWait * time.Duration(attempt)
|
||||
logger.Warnf("⚠️ [%s] Payment request failed: %v, retrying in %v (%d/%d)...",
|
||||
providerTag, err, wait, attempt+1, X402MaxPaymentRetries)
|
||||
time.Sleep(wait)
|
||||
if err := x402Sleep(ctx, wait); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("failed to send payment retry: %w", err)
|
||||
@@ -221,7 +313,9 @@ func DoX402Request(
|
||||
providerTag, resp2.StatusCode, wait, attempt+1, X402MaxPaymentRetries)
|
||||
}
|
||||
|
||||
time.Sleep(wait)
|
||||
if err := x402Sleep(ctx, wait); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -256,26 +350,18 @@ func DoX402RequestStream(
|
||||
providerTag string,
|
||||
logger mcp.Logger,
|
||||
) (*http.Response, error) {
|
||||
// Initial request — use background context (no idle timeout yet).
|
||||
req, err := buildReqFn()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
resp, err := doInitialX402Request(ctx, httpClient, buildReqFn, providerTag, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Non-402 initial response
|
||||
if resp.StatusCode != http.StatusPaymentRequired {
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return resp, nil
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("%s API error (status %d): %s", providerTag, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// 402 — extract payment header and sign
|
||||
paymentHeader := resp.Header.Get("Payment-Required")
|
||||
@@ -314,7 +400,9 @@ func DoX402RequestStream(
|
||||
wait := X402RetryBaseWait * time.Duration(attempt)
|
||||
logger.Warnf("⚠️ [%s] Payment request failed: %v, retrying in %v (%d/%d)...",
|
||||
providerTag, err, wait, attempt+1, X402MaxPaymentRetries)
|
||||
time.Sleep(wait)
|
||||
if err := x402Sleep(ctx, wait); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("failed to send payment retry: %w", err)
|
||||
@@ -369,7 +457,9 @@ func DoX402RequestStream(
|
||||
providerTag, resp2.StatusCode, wait, attempt+1, X402MaxPaymentRetries)
|
||||
}
|
||||
|
||||
time.Sleep(wait)
|
||||
if err := x402Sleep(ctx, wait); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -452,7 +542,8 @@ func X402CallStream(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt
|
||||
var bodyBuf bytes.Buffer
|
||||
tee := io.TeeReader(resp.Body, &bodyBuf)
|
||||
|
||||
text, sseErr := mcp.ParseSSEStream(tee, onChunk, onLine)
|
||||
text, usage, sseErr := mcp.ParseSSEStream(tee, onChunk, onLine)
|
||||
mcp.ReportStreamUsage(usage, c.Provider, c.Model)
|
||||
|
||||
if text != "" {
|
||||
c.Log.Infof("📡 [%s] SSE stream complete, got %d chars", tag, len(text))
|
||||
@@ -499,7 +590,7 @@ func X402Call(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt, user
|
||||
return "", err
|
||||
}
|
||||
|
||||
body, err := DoX402Request(c.HTTPClient, func() (*http.Request, error) {
|
||||
body, err := DoX402Request(context.Background(), c.HTTPClient, func() (*http.Request, error) {
|
||||
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
|
||||
}, signFn, tag, c.Log)
|
||||
if err != nil {
|
||||
@@ -525,7 +616,7 @@ func X402CallFull(c *mcp.Client, signFn X402SignFunc, tag string, req *mcp.Reque
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body, err := DoX402Request(c.HTTPClient, func() (*http.Request, error) {
|
||||
body, err := DoX402Request(x402ContextFromRequest(req), c.HTTPClient, func() (*http.Request, error) {
|
||||
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
|
||||
}, signFn, tag, c.Log)
|
||||
if err != nil {
|
||||
|
||||
52
mcp/payment/x402_test.go
Normal file
52
mcp/payment/x402_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"nofx/mcp"
|
||||
)
|
||||
|
||||
func TestDoX402RequestStreamRetriesInitialServerError(t *testing.T) {
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
call := atomic.AddInt32(&calls, 1)
|
||||
if call == 1 {
|
||||
http.Error(w, "temporary upstream failure", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: ok\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
resp, err := DoX402RequestStream(
|
||||
context.Background(),
|
||||
server.Client(),
|
||||
func() (*http.Request, error) {
|
||||
return http.NewRequest(http.MethodPost, server.URL, nil)
|
||||
},
|
||||
func(string) (string, error) { return "unused", nil },
|
||||
"test-claw402",
|
||||
mcp.NewNoopLogger(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("DoX402RequestStream returned error: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll returned error: %v", err)
|
||||
}
|
||||
if got := string(body); got != "data: ok\n\n" {
|
||||
t.Fatalf("body = %q, want SSE body", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||
t.Fatalf("calls = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
const (
|
||||
DefaultGeminiBaseURL = "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||
DefaultGeminiModel = "gemini-3-pro-preview"
|
||||
DefaultGeminiModel = "gemini-3.1-pro"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package mcp
|
||||
|
||||
import "context"
|
||||
|
||||
// Message represents a conversation message.
|
||||
// Supports plain messages (Role+Content), assistant tool-call messages (ToolCalls),
|
||||
// and tool result messages (Role="tool", ToolCallID, Content).
|
||||
type Message struct {
|
||||
Role string `json:"role"` // "system", "user", "assistant", "tool"
|
||||
Content string `json:"content,omitempty"` // Text content (omitted when ToolCalls present)
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"` // Thinking-model reasoning (must be echoed back in multi-turn)
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // Set by assistant when calling tools
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // Set on role="tool" result messages
|
||||
}
|
||||
@@ -28,6 +31,7 @@ type ToolCallFunction struct {
|
||||
// Exactly one of the two fields will be non-empty for a well-formed response.
|
||||
type LLMResponse struct {
|
||||
Content string // Plain-text reply (final answer)
|
||||
ReasoningContent string // Thinking-model reasoning content
|
||||
ToolCalls []ToolCall // Structured tool invocations
|
||||
}
|
||||
|
||||
@@ -62,6 +66,9 @@ type Request struct {
|
||||
// Advanced features
|
||||
Tools []Tool `json:"tools,omitempty"` // Available tools list
|
||||
ToolChoice string `json:"tool_choice,omitempty"` // Tool choice strategy ("auto", "none", {"type": "function", "function": {"name": "xxx"}})
|
||||
|
||||
// Context for cancellation; not serialized.
|
||||
Ctx context.Context `json:"-"`
|
||||
}
|
||||
|
||||
// NewMessage creates a message
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
@@ -342,6 +347,110 @@ func TestClient_CallWithRequest_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CallWithRequest_AttachesRequestContextToHTTP(t *testing.T) {
|
||||
type contextKey string
|
||||
const key contextKey = "stage"
|
||||
ctx := context.WithValue(context.Background(), key, "planner")
|
||||
|
||||
mockHTTP := NewMockHTTPClient()
|
||||
mockHTTP.ResponseFunc = func(req *http.Request) (*http.Response, error) {
|
||||
if req.Context().Value(key) != "planner" {
|
||||
t.Fatalf("expected HTTP request to inherit mcp.Request context")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
|
||||
client := NewClient(
|
||||
WithHTTPClient(mockHTTP.ToHTTPClient()),
|
||||
WithLogger(NewMockLogger()),
|
||||
WithAPIKey("sk-test-key"),
|
||||
)
|
||||
request := NewRequestBuilder().WithUserPrompt("Hello").MustBuild()
|
||||
request.Ctx = ctx
|
||||
|
||||
result, err := client.CallWithRequest(request)
|
||||
if err != nil {
|
||||
t.Fatalf("should not error: %v", err)
|
||||
}
|
||||
if result != "ok" {
|
||||
t.Fatalf("expected ok, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CallWithRequest_RetrySleepStopsWhenContextCancelled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
mockHTTP := NewMockHTTPClient()
|
||||
mockHTTP.SetNetworkError(io.EOF)
|
||||
client := NewClient(
|
||||
WithHTTPClient(mockHTTP.ToHTTPClient()),
|
||||
WithLogger(NewMockLogger()),
|
||||
WithAPIKey("sk-test-key"),
|
||||
WithMaxRetries(2),
|
||||
WithRetryWaitBase(time.Hour),
|
||||
)
|
||||
request := NewRequestBuilder().WithUserPrompt("Hello").MustBuild()
|
||||
request.Ctx = ctx
|
||||
|
||||
start := time.Now()
|
||||
_, err := client.CallWithRequest(request)
|
||||
if err == nil || !strings.Contains(err.Error(), "context canceled") {
|
||||
t.Fatalf("expected context canceled during retry wait, got %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("retry sleep did not respect context cancellation, elapsed=%v", elapsed)
|
||||
}
|
||||
if got := len(mockHTTP.GetRequests()); got != 1 {
|
||||
t.Fatalf("expected no retry after context cancellation, got %d requests", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CallWithRequest_RetriesUpstreamEmptyOutput(t *testing.T) {
|
||||
mockHTTP := NewMockHTTPClient()
|
||||
attempts := 0
|
||||
mockHTTP.ResponseFunc = func(req *http.Request) (*http.Response, error) {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
body := `{"error":{"code":"upstream_empty_output","message":"Upstream model returned empty output.","type":"rate_limit_error"}}`
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok after retry"}}]}`)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
|
||||
client := NewClient(
|
||||
WithHTTPClient(mockHTTP.ToHTTPClient()),
|
||||
WithLogger(NewMockLogger()),
|
||||
WithAPIKey("sk-test-key"),
|
||||
WithMaxRetries(2),
|
||||
WithRetryWaitBase(time.Millisecond),
|
||||
)
|
||||
request := NewRequestBuilder().WithUserPrompt("Hello").MustBuild()
|
||||
|
||||
result, err := client.CallWithRequest(request)
|
||||
if err != nil {
|
||||
t.Fatalf("should retry upstream empty output and succeed: %v", err)
|
||||
}
|
||||
if result != "ok after retry" {
|
||||
t.Fatalf("expected retry result, got %q", result)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("expected 2 attempts, got %d", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CallWithRequest_MultiRound(t *testing.T) {
|
||||
mockHTTP := NewMockHTTPClient()
|
||||
mockHTTP.SetSuccessResponse("Multi-round response")
|
||||
|
||||
@@ -27,10 +27,10 @@ func TestKlineDaily(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Log("=== BTCUSDT 日线 K线数据 (coinank_api 免费接口) ===")
|
||||
t.Log("=== BTCUSDT daily K-line data (coinank_api free endpoint) ===")
|
||||
for i, k := range resp {
|
||||
startTime := time.UnixMilli(k.StartTime).Format("2006-01-02 15:04:05")
|
||||
t.Logf("\n[%d] 时间: %s", i, startTime)
|
||||
t.Logf("\n[%d] Time: %s", i, startTime)
|
||||
t.Logf(" Open: %.2f", k.Open)
|
||||
t.Logf(" High: %.2f", k.High)
|
||||
t.Logf(" Low: %.2f", k.Low)
|
||||
@@ -39,15 +39,15 @@ func TestKlineDaily(t *testing.T) {
|
||||
t.Logf(" Quantity: %.4f (k[7])", k.Quantity)
|
||||
t.Logf(" Count: %.0f (k[8])", k.Count)
|
||||
|
||||
// 计算验证
|
||||
// Calculation verification
|
||||
if k.Close > 0 && k.Volume > 0 {
|
||||
t.Logf(" --- 验证 ---")
|
||||
t.Logf(" --- verification ---")
|
||||
t.Logf(" Volume × Close = %.2f", k.Volume*k.Close)
|
||||
t.Logf(" Quantity / Close = %.4f", k.Quantity/k.Close)
|
||||
}
|
||||
}
|
||||
|
||||
// 打印原始 JSON
|
||||
// Print raw JSON
|
||||
res, _ := json.MarshalIndent(resp, "", " ")
|
||||
fmt.Printf("\n原始 JSON:\n%s\n", res)
|
||||
fmt.Printf("\nRaw JSON:\n%s\n", res)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -17,10 +19,34 @@ const (
|
||||
cacheDuration = 24 * time.Hour // Cache for 24 hours
|
||||
)
|
||||
|
||||
// CoinInfo represents basic coin information
|
||||
// CoinInfo represents basic Hyperliquid market information.
|
||||
type CoinInfo struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Volume24h float64 `json:"volume_24h"` // 24h volume in USD
|
||||
Volume24h float64 `json:"volume_24h"` // 24h notional volume in USD
|
||||
MarkPrice float64 `json:"mark_price"`
|
||||
PrevDayPrice float64 `json:"prev_day_price,omitempty"`
|
||||
Change24hPct float64 `json:"change_24h_pct,omitempty"`
|
||||
MaxLeverage int `json:"max_leverage,omitempty"`
|
||||
SzDecimals int `json:"sz_decimals,omitempty"`
|
||||
}
|
||||
|
||||
// XYZCategory returns the NOFX product category for a Hyperliquid XYZ base symbol.
|
||||
func XYZCategory(baseSymbol string) string {
|
||||
baseSymbol = strings.ToUpper(strings.TrimSpace(strings.TrimPrefix(baseSymbol, "xyz:")))
|
||||
switch baseSymbol {
|
||||
case "TSLA", "NVDA", "AAPL", "MSFT", "GOOGL", "GOOG", "AMZN", "META", "NFLX", "AMD", "INTC", "COIN", "MSTR", "PLTR", "HOOD", "CRCL", "SNDK", "MU", "SMSN", "DRAM", "SKHX", "BABA", "ASML", "AVGO", "IONQ", "RGTI", "RKLB", "SMCI", "MARA", "RIOT", "MRVL", "SNOW", "CRM", "ORCL", "ADBE", "PYPL", "SHOP", "UBER", "SPOT", "ABNB", "RDDT", "ARM", "SOFI", "XYZ", "LVMH", "PDD", "NVO", "SONY", "DIS", "WMT", "NKE", "JPM", "BAC", "V", "MA", "JNJ", "PG", "UNH", "HD", "XOM", "CVX", "TM", "RACE", "VOW3", "BMW", "MBG":
|
||||
return "stock"
|
||||
case "GOLD", "SILVER", "COPPER", "NATGAS", "URANIUM", "ALUMINIUM", "PLATINUM", "PALLADIUM", "BRENTOIL", "CL", "CORN", "WHEAT", "TTF":
|
||||
return "commodity"
|
||||
case "SPX", "NDX", "DJI", "VIX", "DAX", "FTSE", "NIKKEI", "HSI", "CSI300", "XYZ100", "XYZ25", "XYZ50":
|
||||
return "index"
|
||||
case "EUR", "GBP", "JPY", "AUD", "CAD", "CHF", "MXN", "BRL", "TRY", "ZAR", "CNH", "KRW":
|
||||
return "forex"
|
||||
case "OPENAI", "ANTHROPIC", "SPACEX", "STRIPE", "FIGMA", "DATBRICKS", "PERPLEXITY", "XAI", "BYTEDANCE", "REVOLUT":
|
||||
return "pre_ipo"
|
||||
default:
|
||||
return "stock"
|
||||
}
|
||||
}
|
||||
|
||||
// CoinProvider provides Hyperliquid coin lists
|
||||
@@ -51,75 +77,154 @@ func GetProvider() *CoinProvider {
|
||||
type metaResponse struct {
|
||||
Universe []struct {
|
||||
Name string `json:"name"`
|
||||
SzDecimals int `json:"szDecimals"`
|
||||
MaxLeverage int `json:"maxLeverage"`
|
||||
} `json:"universe"`
|
||||
}
|
||||
|
||||
// assetCtx represents asset context with volume data
|
||||
// assetCtx represents asset context with market data.
|
||||
type assetCtx struct {
|
||||
DayNtlVlm string `json:"dayNtlVlm"` // 24h notional volume
|
||||
MarkPx string `json:"markPx"`
|
||||
PrevDayPx string `json:"prevDayPx"`
|
||||
}
|
||||
|
||||
// fetchCoins fetches all coins from Hyperliquid API and sorts by volume
|
||||
func (p *CoinProvider) fetchCoins(ctx context.Context) error {
|
||||
// Request metaAndAssetCtxs to get both coin names and volume data
|
||||
reqBody := []byte(`{"type": "metaAndAssetCtxs"}`)
|
||||
func fetchPerpDexCoins(ctx context.Context, client *http.Client, dex string) ([]CoinInfo, error) {
|
||||
reqPayload := map[string]string{"type": "metaAndAssetCtxs"}
|
||||
if dex != "" {
|
||||
reqPayload["dex"] = dex
|
||||
}
|
||||
reqBody, err := json.Marshal(reqPayload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", hyperliquidInfoURL,
|
||||
bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.httpClient.Do(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch coin data: %w", err)
|
||||
return nil, fmt.Errorf("failed to fetch coin data: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Response is an array: [meta, [assetCtxs...]]
|
||||
var rawResp []json.RawMessage
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rawResp); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if len(rawResp) < 2 {
|
||||
return fmt.Errorf("unexpected response format")
|
||||
return nil, fmt.Errorf("unexpected response format")
|
||||
}
|
||||
|
||||
// Parse meta
|
||||
var meta metaResponse
|
||||
if err := json.Unmarshal(rawResp[0], &meta); err != nil {
|
||||
return fmt.Errorf("failed to parse meta: %w", err)
|
||||
return nil, fmt.Errorf("failed to parse meta: %w", err)
|
||||
}
|
||||
|
||||
// Parse asset contexts
|
||||
var ctxs []assetCtx
|
||||
if err := json.Unmarshal(rawResp[1], &ctxs); err != nil {
|
||||
return fmt.Errorf("failed to parse asset contexts: %w", err)
|
||||
return nil, fmt.Errorf("failed to parse asset contexts: %w", err)
|
||||
}
|
||||
|
||||
// Build coin list with volume
|
||||
var coins []CoinInfo
|
||||
coins := make([]CoinInfo, 0, len(meta.Universe))
|
||||
for i, u := range meta.Universe {
|
||||
var vol float64
|
||||
var vol, mark, prevDay, change24hPct float64
|
||||
if i < len(ctxs) {
|
||||
fmt.Sscanf(ctxs[i].DayNtlVlm, "%f", &vol)
|
||||
vol, _ = strconv.ParseFloat(ctxs[i].DayNtlVlm, 64)
|
||||
mark, _ = strconv.ParseFloat(ctxs[i].MarkPx, 64)
|
||||
prevDay, _ = strconv.ParseFloat(ctxs[i].PrevDayPx, 64)
|
||||
if prevDay > 0 && mark > 0 {
|
||||
change24hPct = ((mark - prevDay) / prevDay) * 100
|
||||
}
|
||||
}
|
||||
coins = append(coins, CoinInfo{
|
||||
Symbol: u.Name,
|
||||
Volume24h: vol,
|
||||
MarkPrice: mark,
|
||||
PrevDayPrice: prevDay,
|
||||
Change24hPct: change24hPct,
|
||||
MaxLeverage: u.MaxLeverage,
|
||||
SzDecimals: u.SzDecimals,
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by volume descending
|
||||
sort.Slice(coins, func(i, j int) bool {
|
||||
return coins[i].Volume24h > coins[j].Volume24h
|
||||
})
|
||||
return coins, nil
|
||||
}
|
||||
|
||||
// perpDexCacheTTL bounds how often the perp-dex symbol board is re-fetched.
|
||||
// The tradable symbol list changes rarely; prices/volume on the board are
|
||||
// display hints, so short staleness is far better than hammering the
|
||||
// Hyperliquid API (which rate-limits with 429) on every panel render.
|
||||
const perpDexCacheTTL = 5 * time.Minute
|
||||
|
||||
type perpDexCacheEntry struct {
|
||||
coins []CoinInfo
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
type perpDexCacheStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]perpDexCacheEntry
|
||||
}
|
||||
|
||||
var perpDexCoinCache = &perpDexCacheStore{entries: map[string]perpDexCacheEntry{}}
|
||||
|
||||
// fetchPerpDexCoinsFn is swappable in tests.
|
||||
var fetchPerpDexCoinsFn = fetchPerpDexCoins
|
||||
|
||||
// GetPerpDexCoins returns current tradable USDC perp assets for a given
|
||||
// Hyperliquid dex, served from a TTL cache. When the upstream fetch fails
|
||||
// (e.g. HTTP 429 rate limiting) and stale data exists, the stale board is
|
||||
// served instead of an error so the UI keeps working.
|
||||
func GetPerpDexCoins(ctx context.Context, dex string) ([]CoinInfo, error) {
|
||||
perpDexCoinCache.mu.Lock()
|
||||
defer perpDexCoinCache.mu.Unlock()
|
||||
|
||||
entry, hasCache := perpDexCoinCache.entries[dex]
|
||||
if hasCache && time.Since(entry.fetchedAt) < perpDexCacheTTL {
|
||||
return copyCoins(entry.coins), nil
|
||||
}
|
||||
|
||||
coins, err := fetchPerpDexCoinsFn(ctx, &http.Client{Timeout: 30 * time.Second}, dex)
|
||||
if err != nil {
|
||||
if hasCache {
|
||||
logger.Infof("⚠️ Hyperliquid perp-dex fetch failed (%v); serving cached board for dex %q from %s",
|
||||
err, dex, entry.fetchedAt.Format(time.RFC3339))
|
||||
return copyCoins(entry.coins), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
perpDexCoinCache.entries[dex] = perpDexCacheEntry{coins: coins, fetchedAt: time.Now()}
|
||||
return copyCoins(coins), nil
|
||||
}
|
||||
|
||||
// copyCoins returns a defensive copy so callers cannot mutate the cache.
|
||||
func copyCoins(coins []CoinInfo) []CoinInfo {
|
||||
out := make([]CoinInfo, len(coins))
|
||||
copy(out, coins)
|
||||
return out
|
||||
}
|
||||
|
||||
// fetchCoins fetches all default Hyperliquid crypto coins and sorts by volume
|
||||
func (p *CoinProvider) fetchCoins(ctx context.Context) error {
|
||||
coins, err := fetchPerpDexCoins(ctx, p.httpClient, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
@@ -239,12 +239,108 @@ type AssetInfo struct {
|
||||
// Examples:
|
||||
// - "BTCUSDT" -> "BTC"
|
||||
// - "TSLA-USDC" -> "TSLA"
|
||||
// - "TESLA-USDC" -> "TSLA"
|
||||
// - "SAMSUNG-USDC" -> "SMSN"
|
||||
// - "xyz:TSLA" -> "TSLA"
|
||||
// - "BTC" -> "BTC"
|
||||
func NormalizeCoin(symbol string) string {
|
||||
return NormalizeCoinBase(symbol)
|
||||
}
|
||||
|
||||
// XYZDisplayNameToCoin maps user-facing product labels back to Hyperliquid xyz coin names.
|
||||
// Hyperliquid routes candles/orders by short names (for example xyz:SMSN), while NOFX
|
||||
// shows full names (for example SAMSUNG-USDC) in the UI.
|
||||
var XYZDisplayNameToCoin = map[string]string{
|
||||
"TESLA": "TSLA",
|
||||
"NVIDIA": "NVDA",
|
||||
"ROBINHOOD": "HOOD",
|
||||
"INTEL": "INTC",
|
||||
"PALANTIR": "PLTR",
|
||||
"COINBASE": "COIN",
|
||||
"APPLE": "AAPL",
|
||||
"MICROSOFT": "MSFT",
|
||||
"ORACLE": "ORCL",
|
||||
"GOOGLE": "GOOGL",
|
||||
"ALPHABET": "GOOGL",
|
||||
"AMAZON": "AMZN",
|
||||
"MICRON": "MU",
|
||||
"SANDISK": "SNDK",
|
||||
"MICROSTRATEGY": "MSTR",
|
||||
"CIRCLE": "CRCL",
|
||||
"NETFLIX": "NFLX",
|
||||
"COSTCO": "COST",
|
||||
"ELI-LILLY": "LLY",
|
||||
"SK-HYNIX": "SKHX",
|
||||
"SKHYNIX": "SKHX",
|
||||
"TSMC": "TSM",
|
||||
"RIVIAN": "RIVN",
|
||||
"ALIBABA": "BABA",
|
||||
"CRUDE-OIL": "CL",
|
||||
"CRUDEOIL": "CL",
|
||||
"NATURAL-GAS": "NATGAS",
|
||||
"NATURALGAS": "NATGAS",
|
||||
"SAMSUNG": "SMSN",
|
||||
"USA-RARE-EARTH": "USAR",
|
||||
"USARAREEARTH": "USAR",
|
||||
"COREWEAVE": "CRWV",
|
||||
"DOLLAR-INDEX": "DXY",
|
||||
"DOLLARINDEX": "DXY",
|
||||
"GAMESTOP": "GME",
|
||||
"KOREA-200": "KR200",
|
||||
"KOREA200": "KR200",
|
||||
"JAPAN-225": "JP225",
|
||||
"JAPAN225": "JP225",
|
||||
"SOUTH-KOREA-ETF": "EWY",
|
||||
"SOUTHKOREAETF": "EWY",
|
||||
"JAPAN-ETF": "EWJ",
|
||||
"JAPANETF": "EWJ",
|
||||
"BRENT-OIL": "BRENTOIL",
|
||||
"BRENTOIL": "BRENTOIL",
|
||||
"HIMS-HERS": "HIMS",
|
||||
"HIMSHERS": "HIMS",
|
||||
"S&P-500": "SP500",
|
||||
"SP-500": "SP500",
|
||||
"SP500": "SP500",
|
||||
"DRAFTKINGS": "DKNG",
|
||||
"LITECOIN": "LITE",
|
||||
"ENERGY-SECTOR-ETF": "XLE",
|
||||
"ENERGYSECTORETF": "XLE",
|
||||
"TTF-GAS": "TTF",
|
||||
"TTFGAS": "TTF",
|
||||
"BLACKSTONE": "BX",
|
||||
"MARVELL": "MRVL",
|
||||
"ROCKET-LAB": "RKLB",
|
||||
"ROCKETLAB": "RKLB",
|
||||
"VOLATILITY": "VOL",
|
||||
"COINBASE-PRE-IPO": "CBRS",
|
||||
"COINBASEPREIPO": "CBRS",
|
||||
"BRAZIL-ETF": "EWZ",
|
||||
"BRAZILETF": "EWZ",
|
||||
"ZOOM": "ZM",
|
||||
"NIFTY-50": "NIFTY",
|
||||
"NIFTY50": "NIFTY",
|
||||
"TAIWAN-ETF": "EWT",
|
||||
"TAIWANETF": "EWT",
|
||||
"SPACEX-PRE-IPO": "SPCX",
|
||||
"SPACEXPREIPO": "SPCX",
|
||||
"IBOVESPA": "IBOV",
|
||||
}
|
||||
|
||||
func NormalizeXYZAlias(base string) string {
|
||||
base = strings.ToUpper(strings.TrimSpace(base))
|
||||
base = strings.TrimPrefix(base, "XYZ:")
|
||||
base = strings.TrimSuffix(base, "-USDC")
|
||||
base = strings.TrimSuffix(base, "-USD")
|
||||
if mapped, ok := XYZDisplayNameToCoin[base]; ok {
|
||||
return mapped
|
||||
}
|
||||
compact := strings.NewReplacer(" ", "", "_", "", ".", "", "/", "", "&", "AND").Replace(base)
|
||||
if mapped, ok := XYZDisplayNameToCoin[compact]; ok {
|
||||
return mapped
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// MapTimeframe maps common timeframe strings to Hyperliquid format
|
||||
func MapTimeframe(interval string) string {
|
||||
switch interval {
|
||||
@@ -364,8 +460,21 @@ func IsStockPerp(symbol string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsXYZAsset checks if a symbol is on the xyz dex (stocks, forex, commodities)
|
||||
// IsXYZAsset checks if a symbol is on the xyz dex (stocks, forex, commodities).
|
||||
//
|
||||
// Detection is suffix-driven first, hardcoded-list second:
|
||||
// 1. `xyz:` prefix or `-USDC` suffix are unambiguous Hyperliquid signals —
|
||||
// the only place those tokens originate is the Hyperliquid USDC board.
|
||||
// This unblocks newly-listed stock perpetuals (QNT, ARM, ...) without
|
||||
// requiring a code change every time Hyperliquid adds a ticker.
|
||||
// 2. Bare bases (e.g. "QNT" with no qualifying suffix) still fall back to
|
||||
// the hardcoded StockPerpsSymbols / XYZOtherSymbols / display alias lists
|
||||
// so callers passing pre-normalized base symbols continue to work.
|
||||
func IsXYZAsset(symbol string) bool {
|
||||
trimmed := strings.ToUpper(strings.TrimSpace(symbol))
|
||||
if strings.HasPrefix(strings.ToLower(trimmed), "xyz:") || strings.HasSuffix(trimmed, "-USDC") {
|
||||
return true
|
||||
}
|
||||
coin := NormalizeCoinBase(symbol)
|
||||
// Check stock perps
|
||||
for _, s := range StockPerpsSymbols {
|
||||
@@ -379,18 +488,26 @@ func IsXYZAsset(symbol string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Check newer xyz assets that are represented by full display-name aliases in NOFX.
|
||||
for _, s := range XYZDisplayNameToCoin {
|
||||
if s == coin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NormalizeCoinBase removes common suffixes to get base symbol
|
||||
func NormalizeCoinBase(symbol string) string {
|
||||
symbol = strings.ToUpper(strings.TrimSpace(symbol))
|
||||
hasXYZPrefix := strings.HasPrefix(symbol, "XYZ:")
|
||||
// Remove xyz: prefix if present
|
||||
if strings.HasPrefix(symbol, "xyz:") {
|
||||
return strings.TrimPrefix(symbol, "xyz:")
|
||||
if hasXYZPrefix {
|
||||
return NormalizeXYZAlias(strings.TrimPrefix(symbol, "XYZ:"))
|
||||
}
|
||||
// Remove -USDC suffix
|
||||
if strings.HasSuffix(symbol, "-USDC") {
|
||||
return strings.TrimSuffix(symbol, "-USDC")
|
||||
return NormalizeXYZAlias(strings.TrimSuffix(symbol, "-USDC"))
|
||||
}
|
||||
// Remove USDT suffix
|
||||
if strings.HasSuffix(symbol, "USDT") {
|
||||
@@ -400,14 +517,26 @@ func NormalizeCoinBase(symbol string) string {
|
||||
if strings.HasSuffix(symbol, "USD") {
|
||||
return strings.TrimSuffix(symbol, "USD")
|
||||
}
|
||||
return symbol
|
||||
return NormalizeXYZAlias(symbol)
|
||||
}
|
||||
|
||||
// FormatCoinForAPI formats the coin name for Hyperliquid API
|
||||
// Stock perps need xyz:SYMBOL format, crypto uses plain symbol
|
||||
// FormatCoinForAPI formats the coin name for Hyperliquid API.
|
||||
// Stock perps need xyz:SYMBOL format, crypto uses plain symbol.
|
||||
//
|
||||
// Decision order:
|
||||
// 1. `xyz:` prefix OR `-USDC` suffix on the original input ⇒ xyz asset
|
||||
// (these tokens are Hyperliquid-specific, so the answer is unambiguous
|
||||
// regardless of whether the base symbol appears in our hardcoded lists).
|
||||
// 2. After stripping suffixes, if the bare base matches a known xyz asset
|
||||
// (stock perps, forex, commodities, display aliases) ⇒ also xyz.
|
||||
// 3. Otherwise crypto.
|
||||
func FormatCoinForAPI(symbol string) string {
|
||||
trimmed := strings.TrimSpace(symbol)
|
||||
upper := strings.ToUpper(trimmed)
|
||||
hasExplicitXYZ := strings.HasPrefix(strings.ToLower(trimmed), "xyz:")
|
||||
hasUSDCSuffix := strings.HasSuffix(upper, "-USDC")
|
||||
base := NormalizeCoinBase(symbol)
|
||||
if IsXYZAsset(base) {
|
||||
if hasExplicitXYZ || hasUSDCSuffix || IsXYZAsset(base) {
|
||||
return "xyz:" + base
|
||||
}
|
||||
return base
|
||||
|
||||
@@ -16,10 +16,10 @@ func TestGetCandles_BTC(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Log("=== BTC 日线数据 (Hyperliquid) ===")
|
||||
t.Log("=== BTC daily data (Hyperliquid) ===")
|
||||
for i, c := range candles {
|
||||
openTime := time.UnixMilli(c.OpenTime).Format("2006-01-02 15:04:05")
|
||||
t.Logf("\n[%d] 时间: %s", i, openTime)
|
||||
t.Logf("\n[%d] Time: %s", i, openTime)
|
||||
t.Logf(" Symbol: %s", c.Symbol)
|
||||
t.Logf(" Interval: %s", c.Interval)
|
||||
t.Logf(" Open: %s", c.Open)
|
||||
@@ -30,24 +30,24 @@ func TestGetCandles_BTC(t *testing.T) {
|
||||
t.Logf(" TradeCount: %d", c.TradeCount)
|
||||
}
|
||||
|
||||
// 打印原始 JSON
|
||||
// Print raw JSON
|
||||
res, _ := json.MarshalIndent(candles, "", " ")
|
||||
fmt.Printf("\n原始 JSON:\n%s\n", res)
|
||||
fmt.Printf("\nRaw JSON:\n%s\n", res)
|
||||
}
|
||||
|
||||
func TestGetCandles_TSLA(t *testing.T) {
|
||||
client := NewClient()
|
||||
|
||||
// 测试股票永续合约 - 使用 xyz dex
|
||||
// Test stock perpetual contracts - using xyz dex
|
||||
candles, err := client.GetCandles(context.TODO(), "TSLA", "1d", 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Log("=== TSLA 日线数据 (Hyperliquid xyz dex) ===")
|
||||
t.Log("=== TSLA daily data (Hyperliquid xyz dex) ===")
|
||||
for i, c := range candles {
|
||||
openTime := time.UnixMilli(c.OpenTime).Format("2006-01-02 15:04:05")
|
||||
t.Logf("\n[%d] 时间: %s", i, openTime)
|
||||
t.Logf("\n[%d] Time: %s", i, openTime)
|
||||
t.Logf(" Symbol: %s", c.Symbol)
|
||||
t.Logf(" Interval: %s", c.Interval)
|
||||
t.Logf(" Open: %s", c.Open)
|
||||
@@ -58,33 +58,33 @@ func TestGetCandles_TSLA(t *testing.T) {
|
||||
t.Logf(" TradeCount: %d", c.TradeCount)
|
||||
}
|
||||
|
||||
// 打印原始 JSON
|
||||
// Print raw JSON
|
||||
res, _ := json.MarshalIndent(candles, "", " ")
|
||||
fmt.Printf("\n原始 JSON:\n%s\n", res)
|
||||
fmt.Printf("\nRaw JSON:\n%s\n", res)
|
||||
}
|
||||
|
||||
func TestGetCandles_StockPerps(t *testing.T) {
|
||||
client := NewClient()
|
||||
|
||||
// 测试多个股票永续合约 (xyz dex)
|
||||
// Test multiple stock perpetual contracts (xyz dex)
|
||||
symbols := []string{"TSLA", "NVDA", "AAPL", "MSFT"}
|
||||
|
||||
for _, symbol := range symbols {
|
||||
t.Logf("\n=== %s 日线数据 ===", symbol)
|
||||
t.Logf("\n=== %s daily data ===", symbol)
|
||||
candles, err := client.GetCandles(context.TODO(), symbol, "1d", 3)
|
||||
if err != nil {
|
||||
t.Errorf("%s 获取失败: %v", symbol, err)
|
||||
t.Errorf("%s fetch failed: %v", symbol, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(candles) == 0 {
|
||||
t.Logf("%s: 无数据", symbol)
|
||||
t.Logf("%s: no data", symbol)
|
||||
continue
|
||||
}
|
||||
|
||||
latest := candles[len(candles)-1]
|
||||
openTime := time.UnixMilli(latest.OpenTime).Format("2006-01-02")
|
||||
t.Logf("%s 最新: %s Open=%s High=%s Low=%s Close=%s Vol=%s",
|
||||
t.Logf("%s latest: %s Open=%s High=%s Low=%s Close=%s Vol=%s",
|
||||
symbol, openTime, latest.Open, latest.High, latest.Low, latest.Close, latest.Volume)
|
||||
}
|
||||
}
|
||||
@@ -97,19 +97,19 @@ func TestGetAllMids(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Log("=== 加密货币资产中间价 (默认 dex) ===")
|
||||
t.Log("=== Crypto asset mid prices (default dex) ===")
|
||||
|
||||
// 显示一些主要加密货币资产
|
||||
// Show some major crypto assets
|
||||
cryptoAssets := []string{"BTC", "ETH", "SOL", "DOGE", "XRP"}
|
||||
for _, asset := range cryptoAssets {
|
||||
if mid, ok := mids[asset]; ok {
|
||||
t.Logf("%s: %s", asset, mid)
|
||||
} else {
|
||||
t.Logf("%s: 不存在", asset)
|
||||
t.Logf("%s: not found", asset)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("\n总共 %d 个加密货币交易对", len(mids))
|
||||
t.Logf("\nTotal %d crypto trading pairs", len(mids))
|
||||
}
|
||||
|
||||
func TestGetAllMidsXYZ(t *testing.T) {
|
||||
@@ -120,14 +120,14 @@ func TestGetAllMidsXYZ(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Log("=== xyz dex 资产中间价 (股票、外汇、大宗商品) ===")
|
||||
t.Log("=== xyz dex asset mid prices (stocks, forex, commodities) ===")
|
||||
|
||||
// 显示所有 xyz dex 资产
|
||||
// Show all xyz dex assets
|
||||
for symbol, mid := range mids {
|
||||
t.Logf("%s: %s", symbol, mid)
|
||||
}
|
||||
|
||||
t.Logf("\n总共 %d 个 xyz dex 交易对", len(mids))
|
||||
t.Logf("\nTotal %d xyz dex trading pairs", len(mids))
|
||||
}
|
||||
|
||||
func TestGetMeta(t *testing.T) {
|
||||
@@ -138,11 +138,11 @@ func TestGetMeta(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Log("=== 资产元数据 ===")
|
||||
t.Logf("总共 %d 个资产", len(meta.Universe))
|
||||
t.Log("=== Asset metadata ===")
|
||||
t.Logf("Total %d assets", len(meta.Universe))
|
||||
|
||||
// 显示股票永续合约
|
||||
t.Log("\n股票永续合约:")
|
||||
// Show stock perpetual contracts
|
||||
t.Log("\nStock perpetual contracts:")
|
||||
for _, asset := range meta.Universe {
|
||||
if IsStockPerp(asset.Name) {
|
||||
t.Logf(" %s: szDecimals=%d, maxLeverage=%d", asset.Name, asset.SzDecimals, asset.MaxLeverage)
|
||||
@@ -159,6 +159,10 @@ func TestNormalizeCoin(t *testing.T) {
|
||||
{"BTCUSDT", "BTC"},
|
||||
{"BTCUSD", "BTC"},
|
||||
{"TSLA-USDC", "TSLA"},
|
||||
{"TESLA-USDC", "TSLA"},
|
||||
{"SMSN-USDC", "SMSN"},
|
||||
{"SAMSUNG-USDC", "SMSN"},
|
||||
{"xyz:SMSN", "SMSN"},
|
||||
{"AAPL-USDC", "AAPL"},
|
||||
{"ETH", "ETH"},
|
||||
{"ETHUSDT", "ETH"},
|
||||
@@ -204,6 +208,10 @@ func TestFormatCoinForAPI(t *testing.T) {
|
||||
{"ETH", "ETH"},
|
||||
{"TSLA", "xyz:TSLA"},
|
||||
{"TSLA-USDC", "xyz:TSLA"},
|
||||
{"TESLA-USDC", "xyz:TSLA"},
|
||||
{"SMSN-USDC", "xyz:SMSN"},
|
||||
{"SAMSUNG-USDC", "xyz:SMSN"},
|
||||
{"xyz:SMSN", "xyz:SMSN"},
|
||||
{"xyz:TSLA", "xyz:TSLA"},
|
||||
{"NVDA", "xyz:NVDA"},
|
||||
{"GOLD", "xyz:GOLD"},
|
||||
|
||||
113
provider/hyperliquid/perp_dex_cache_test.go
Normal file
113
provider/hyperliquid/perp_dex_cache_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package hyperliquid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// withStubbedPerpDexFetch swaps the live fetch function and resets the cache,
|
||||
// restoring both when the test finishes.
|
||||
func withStubbedPerpDexFetch(t *testing.T, fn func(ctx context.Context, client *http.Client, dex string) ([]CoinInfo, error)) {
|
||||
t.Helper()
|
||||
original := fetchPerpDexCoinsFn
|
||||
fetchPerpDexCoinsFn = fn
|
||||
perpDexCoinCache.mu.Lock()
|
||||
perpDexCoinCache.entries = map[string]perpDexCacheEntry{}
|
||||
perpDexCoinCache.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
fetchPerpDexCoinsFn = original
|
||||
perpDexCoinCache.mu.Lock()
|
||||
perpDexCoinCache.entries = map[string]perpDexCacheEntry{}
|
||||
perpDexCoinCache.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetPerpDexCoinsCachesWithinTTL(t *testing.T) {
|
||||
calls := 0
|
||||
withStubbedPerpDexFetch(t, func(ctx context.Context, client *http.Client, dex string) ([]CoinInfo, error) {
|
||||
calls++
|
||||
return []CoinInfo{{Symbol: "xyz:TSLA", MarkPrice: 400}}, nil
|
||||
})
|
||||
|
||||
first, err := GetPerpDexCoins(context.Background(), "xyz")
|
||||
if err != nil {
|
||||
t.Fatalf("first call: %v", err)
|
||||
}
|
||||
second, err := GetPerpDexCoins(context.Background(), "xyz")
|
||||
if err != nil {
|
||||
t.Fatalf("second call: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("fetch calls = %d, want 1 (second call must hit cache)", calls)
|
||||
}
|
||||
if len(first) != 1 || len(second) != 1 || second[0].Symbol != "xyz:TSLA" {
|
||||
t.Fatalf("unexpected results: first=%v second=%v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPerpDexCoinsServesStaleOnUpstreamError(t *testing.T) {
|
||||
calls := 0
|
||||
withStubbedPerpDexFetch(t, func(ctx context.Context, client *http.Client, dex string) ([]CoinInfo, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return []CoinInfo{{Symbol: "xyz:NVDA", MarkPrice: 1000}}, nil
|
||||
}
|
||||
return nil, errors.New("API returned status 429")
|
||||
})
|
||||
|
||||
if _, err := GetPerpDexCoins(context.Background(), "xyz"); err != nil {
|
||||
t.Fatalf("first call: %v", err)
|
||||
}
|
||||
|
||||
// Expire the cache so the next call must attempt a refresh.
|
||||
perpDexCoinCache.mu.Lock()
|
||||
entry := perpDexCoinCache.entries["xyz"]
|
||||
entry.fetchedAt = time.Now().Add(-2 * perpDexCacheTTL)
|
||||
perpDexCoinCache.entries["xyz"] = entry
|
||||
perpDexCoinCache.mu.Unlock()
|
||||
|
||||
coins, err := GetPerpDexCoins(context.Background(), "xyz")
|
||||
if err != nil {
|
||||
t.Fatalf("expected stale data instead of error, got: %v", err)
|
||||
}
|
||||
if len(coins) != 1 || coins[0].Symbol != "xyz:NVDA" {
|
||||
t.Fatalf("expected stale NVDA entry, got %v", coins)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("fetch calls = %d, want 2 (refresh attempted)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPerpDexCoinsErrorsWithoutAnyCache(t *testing.T) {
|
||||
withStubbedPerpDexFetch(t, func(ctx context.Context, client *http.Client, dex string) ([]CoinInfo, error) {
|
||||
return nil, errors.New("API returned status 429")
|
||||
})
|
||||
|
||||
if _, err := GetPerpDexCoins(context.Background(), "xyz"); err == nil {
|
||||
t.Fatal("expected error when upstream fails and no cache exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPerpDexCoinsCachesPerDex(t *testing.T) {
|
||||
withStubbedPerpDexFetch(t, func(ctx context.Context, client *http.Client, dex string) ([]CoinInfo, error) {
|
||||
if dex == "xyz" {
|
||||
return []CoinInfo{{Symbol: "xyz:AAPL"}}, nil
|
||||
}
|
||||
return []CoinInfo{{Symbol: "BTC"}}, nil
|
||||
})
|
||||
|
||||
xyz, err := GetPerpDexCoins(context.Background(), "xyz")
|
||||
if err != nil {
|
||||
t.Fatalf("xyz: %v", err)
|
||||
}
|
||||
def, err := GetPerpDexCoins(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("default dex: %v", err)
|
||||
}
|
||||
if xyz[0].Symbol != "xyz:AAPL" || def[0].Symbol != "BTC" {
|
||||
t.Fatalf("cache keys collided: xyz=%v default=%v", xyz, def)
|
||||
}
|
||||
}
|
||||
60
provider/nofxos/ai500_cache.go
Normal file
60
provider/nofxos/ai500_cache.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package nofxos
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ai500CacheTTL bounds how often the AI500 board is re-fetched. The list is
|
||||
// refreshed upstream on the order of minutes, every claw402-routed call costs
|
||||
// money, and the agent UI polls this for display — so short staleness is
|
||||
// preferable to per-render upstream calls.
|
||||
const ai500CacheTTL = 5 * time.Minute
|
||||
|
||||
type ai500CacheStore struct {
|
||||
mu sync.Mutex
|
||||
coins []CoinData
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
var ai500Cache = &ai500CacheStore{}
|
||||
|
||||
// fetchAI500ListFn is swappable in tests.
|
||||
var fetchAI500ListFn = func(c *Client) ([]CoinData, error) {
|
||||
return c.GetAI500List()
|
||||
}
|
||||
|
||||
// GetAI500ListCached returns the AI500 coin list served from a TTL cache.
|
||||
// When the upstream fetch fails and stale data exists, the stale board is
|
||||
// served instead of an error so displays keep working through flakiness.
|
||||
func GetAI500ListCached(c *Client) ([]CoinData, error) {
|
||||
ai500Cache.mu.Lock()
|
||||
defer ai500Cache.mu.Unlock()
|
||||
|
||||
hasCache := len(ai500Cache.coins) > 0
|
||||
if hasCache && time.Since(ai500Cache.fetchedAt) < ai500CacheTTL {
|
||||
return copyCoinData(ai500Cache.coins), nil
|
||||
}
|
||||
|
||||
coins, err := fetchAI500ListFn(c)
|
||||
if err != nil {
|
||||
if hasCache {
|
||||
log.Printf("⚠️ AI500 fetch failed (%v); serving cached list from %s",
|
||||
err, ai500Cache.fetchedAt.Format(time.RFC3339))
|
||||
return copyCoinData(ai500Cache.coins), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ai500Cache.coins = coins
|
||||
ai500Cache.fetchedAt = time.Now()
|
||||
return copyCoinData(coins), nil
|
||||
}
|
||||
|
||||
// copyCoinData returns a defensive copy so callers cannot mutate the cache.
|
||||
func copyCoinData(coins []CoinData) []CoinData {
|
||||
out := make([]CoinData, len(coins))
|
||||
copy(out, coins)
|
||||
return out
|
||||
}
|
||||
86
provider/nofxos/ai500_cache_test.go
Normal file
86
provider/nofxos/ai500_cache_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package nofxos
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func withStubbedAI500Fetch(t *testing.T, fn func(c *Client) ([]CoinData, error)) {
|
||||
t.Helper()
|
||||
original := fetchAI500ListFn
|
||||
fetchAI500ListFn = fn
|
||||
ai500Cache.mu.Lock()
|
||||
ai500Cache.coins = nil
|
||||
ai500Cache.fetchedAt = time.Time{}
|
||||
ai500Cache.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
fetchAI500ListFn = original
|
||||
ai500Cache.mu.Lock()
|
||||
ai500Cache.coins = nil
|
||||
ai500Cache.fetchedAt = time.Time{}
|
||||
ai500Cache.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetAI500ListCachedWithinTTL(t *testing.T) {
|
||||
calls := 0
|
||||
withStubbedAI500Fetch(t, func(c *Client) ([]CoinData, error) {
|
||||
calls++
|
||||
return []CoinData{{Pair: "BTCUSDT", Score: 95.5}}, nil
|
||||
})
|
||||
|
||||
client := NewClient("", "")
|
||||
first, err := GetAI500ListCached(client)
|
||||
if err != nil {
|
||||
t.Fatalf("first call: %v", err)
|
||||
}
|
||||
second, err := GetAI500ListCached(client)
|
||||
if err != nil {
|
||||
t.Fatalf("second call: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("fetch calls = %d, want 1 (second call must hit cache)", calls)
|
||||
}
|
||||
if len(first) != 1 || len(second) != 1 || second[0].Pair != "BTCUSDT" {
|
||||
t.Fatalf("unexpected results: first=%v second=%v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAI500ListCachedServesStaleOnError(t *testing.T) {
|
||||
calls := 0
|
||||
withStubbedAI500Fetch(t, func(c *Client) ([]CoinData, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return []CoinData{{Pair: "ETHUSDT", Score: 88}}, nil
|
||||
}
|
||||
return nil, errors.New("API returned status 429")
|
||||
})
|
||||
|
||||
client := NewClient("", "")
|
||||
if _, err := GetAI500ListCached(client); err != nil {
|
||||
t.Fatalf("first call: %v", err)
|
||||
}
|
||||
|
||||
ai500Cache.mu.Lock()
|
||||
ai500Cache.fetchedAt = time.Now().Add(-2 * ai500CacheTTL)
|
||||
ai500Cache.mu.Unlock()
|
||||
|
||||
coins, err := GetAI500ListCached(client)
|
||||
if err != nil {
|
||||
t.Fatalf("expected stale data instead of error, got: %v", err)
|
||||
}
|
||||
if len(coins) != 1 || coins[0].Pair != "ETHUSDT" {
|
||||
t.Fatalf("expected stale ETH entry, got %v", coins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAI500ListCachedErrorsWithoutCache(t *testing.T) {
|
||||
withStubbedAI500Fetch(t, func(c *Client) ([]CoinData, error) {
|
||||
return nil, errors.New("upstream down")
|
||||
})
|
||||
|
||||
if _, err := GetAI500ListCached(NewClient("", "")); err == nil {
|
||||
t.Fatal("expected error when upstream fails with empty cache")
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,7 @@ func (c *Claw402DataClient) DoRequest(endpoint string) ([]byte, error) {
|
||||
signFn := payment.MakeClaw402SignFunc(c.privateKey)
|
||||
|
||||
body, err := payment.DoX402Request(
|
||||
context.Background(),
|
||||
c.httpClient,
|
||||
buildReq,
|
||||
signFn,
|
||||
|
||||
32
provider/nofxos/client_resolve.go
Normal file
32
provider/nofxos/client_resolve.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package nofxos
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"nofx/logger"
|
||||
)
|
||||
|
||||
// ResolveClient returns a nofxos data client, routed through the claw402
|
||||
// x402 payment gateway when a wallet key is available. Resolution order:
|
||||
// the explicit walletKey argument, then the CLAW402_WALLET_KEY environment
|
||||
// variable, then the direct nofxos.ai client with the default auth key.
|
||||
func ResolveClient(walletKey string) *Client {
|
||||
walletKey = strings.TrimSpace(walletKey)
|
||||
if walletKey == "" {
|
||||
walletKey = strings.TrimSpace(os.Getenv("CLAW402_WALLET_KEY"))
|
||||
}
|
||||
client := NewClient(DefaultBaseURL, DefaultAuthKey)
|
||||
if walletKey == "" {
|
||||
return client
|
||||
}
|
||||
|
||||
claw402URL := strings.TrimSpace(os.Getenv("CLAW402_URL"))
|
||||
claw402Client, err := NewClaw402DataClient(claw402URL, walletKey, &logger.MCPLogger{})
|
||||
if err != nil {
|
||||
logger.Warnf("⚠️ Failed to init claw402 data client: %v (using direct nofxos.ai)", err)
|
||||
return client
|
||||
}
|
||||
client.SetClaw402(claw402Client)
|
||||
return client
|
||||
}
|
||||
@@ -118,11 +118,11 @@ func FormatQuantDataForAI(symbol string, data *QuantData, lang Language) string
|
||||
func formatQuantDataZH(symbol string, data *QuantData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("### %s 量化数据\n", symbol))
|
||||
sb.WriteString(fmt.Sprintf("价格: $%.4f\n\n", data.Price))
|
||||
sb.WriteString(fmt.Sprintf("### %s Quant Data\n", symbol))
|
||||
sb.WriteString(fmt.Sprintf("Price: $%.4f\n\n", data.Price))
|
||||
|
||||
if len(data.PriceChange) > 0 {
|
||||
sb.WriteString("**价格变化**:\n")
|
||||
sb.WriteString("**Price Change**:\n")
|
||||
durations := []string{"1h", "4h", "8h", "12h", "24h"}
|
||||
for _, d := range durations {
|
||||
if change, ok := data.PriceChange[d]; ok {
|
||||
@@ -135,14 +135,14 @@ func formatQuantDataZH(symbol string, data *QuantData) string {
|
||||
if len(data.OI) > 0 {
|
||||
for exchange, oiData := range data.OI {
|
||||
if oiData != nil {
|
||||
sb.WriteString(fmt.Sprintf("**%s持仓**:\n", strings.ToUpper(exchange)))
|
||||
sb.WriteString(fmt.Sprintf("**%s Open Interest**:\n", strings.ToUpper(exchange)))
|
||||
sb.WriteString(fmt.Sprintf("- OI: %.2f\n", oiData.CurrentOI))
|
||||
if oiData.NetLong > 0 || oiData.NetShort > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- 多头: %.2f, 空头: %.2f\n", oiData.NetLong, oiData.NetShort))
|
||||
sb.WriteString(fmt.Sprintf("- Long: %.2f, Short: %.2f\n", oiData.NetLong, oiData.NetShort))
|
||||
}
|
||||
if oiData.Delta != nil {
|
||||
if delta, ok := oiData.Delta["1h"]; ok && delta != nil {
|
||||
sb.WriteString(fmt.Sprintf("- 1h变化: %s (%.2f%%)\n",
|
||||
sb.WriteString(fmt.Sprintf("- 1h Change: %s (%.2f%%)\n",
|
||||
formatValue(delta.OIDeltaValue), delta.OIDeltaPercent))
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func formatQuantDataZH(symbol string, data *QuantData) string {
|
||||
}
|
||||
|
||||
if data.Netflow != nil && data.Netflow.Institution != nil && data.Netflow.Institution.Future != nil {
|
||||
sb.WriteString("**机构资金流**:\n")
|
||||
sb.WriteString("**Institution Net Flow**:\n")
|
||||
durations := []string{"1h", "4h", "24h"}
|
||||
for _, d := range durations {
|
||||
if flow, ok := data.Netflow.Institution.Future[d]; ok {
|
||||
|
||||
@@ -131,13 +131,13 @@ func FormatNetFlowRankingForAI(data *NetFlowRankingData, lang Language) string {
|
||||
func formatNetFlowRankingZH(data *NetFlowRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## 资金流向排行 (%s)\n\n", data.Duration))
|
||||
sb.WriteString(fmt.Sprintf("## Net Flow Ranking (%s)\n\n", data.Duration))
|
||||
|
||||
// Institution inflow
|
||||
if len(data.InstitutionFutureTop) > 0 {
|
||||
sb.WriteString("### 机构资金流入榜\n")
|
||||
sb.WriteString("Smart Money买入信号:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 流入金额(USDT) | 价格 |\n")
|
||||
sb.WriteString("### Institution Inflow Ranking\n")
|
||||
sb.WriteString("Smart Money buy signal:\n\n")
|
||||
sb.WriteString("| Rank | Symbol | Inflow Amount(USDT) | Price |\n")
|
||||
sb.WriteString("|------|------|----------------|------|\n")
|
||||
for _, pos := range data.InstitutionFutureTop {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n",
|
||||
@@ -148,9 +148,9 @@ func formatNetFlowRankingZH(data *NetFlowRankingData) string {
|
||||
|
||||
// Institution outflow
|
||||
if len(data.InstitutionFutureLow) > 0 {
|
||||
sb.WriteString("### 机构资金流出榜\n")
|
||||
sb.WriteString("Smart Money卖出信号:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 流出金额(USDT) | 价格 |\n")
|
||||
sb.WriteString("### Institution Outflow Ranking\n")
|
||||
sb.WriteString("Smart Money sell signal:\n\n")
|
||||
sb.WriteString("| Rank | Symbol | Outflow Amount(USDT) | Price |\n")
|
||||
sb.WriteString("|------|------|----------------|------|\n")
|
||||
for _, pos := range data.InstitutionFutureLow {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n",
|
||||
@@ -161,9 +161,9 @@ func formatNetFlowRankingZH(data *NetFlowRankingData) string {
|
||||
|
||||
// Retail flow summary
|
||||
if len(data.PersonalFutureTop) > 0 || len(data.PersonalFutureLow) > 0 {
|
||||
sb.WriteString("### 散户资金动向\n")
|
||||
sb.WriteString("### Retail Capital Movement\n")
|
||||
if len(data.PersonalFutureTop) > 0 {
|
||||
sb.WriteString("散户买入: ")
|
||||
sb.WriteString("Retail buy: ")
|
||||
for i, pos := range data.PersonalFutureTop {
|
||||
if i >= 3 {
|
||||
break
|
||||
@@ -176,7 +176,7 @@ func formatNetFlowRankingZH(data *NetFlowRankingData) string {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if len(data.PersonalFutureLow) > 0 {
|
||||
sb.WriteString("散户卖出: ")
|
||||
sb.WriteString("Retail sell: ")
|
||||
for i, pos := range data.PersonalFutureLow {
|
||||
if i >= 3 {
|
||||
break
|
||||
@@ -191,7 +191,7 @@ func formatNetFlowRankingZH(data *NetFlowRankingData) string {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("**解读**: 机构买入+散户卖出=强烈看多 | 机构卖出+散户买入=强烈看空\n\n")
|
||||
sb.WriteString("**Interpretation**: Institution buy + Retail sell = strongly bullish | Institution sell + Retail buy = strongly bearish\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -169,12 +169,12 @@ func FormatOIRankingForAI(data *OIRankingData, lang Language) string {
|
||||
func formatOIRankingZH(data *OIRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## 持仓量变化排行 (%s)\n\n", data.Duration))
|
||||
sb.WriteString(fmt.Sprintf("## Open Interest Change Ranking (%s)\n\n", data.Duration))
|
||||
|
||||
if len(data.TopPositions) > 0 {
|
||||
sb.WriteString("### 持仓增加榜\n")
|
||||
sb.WriteString("资金流入,趋势延续或新仓建立信号:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 持仓变化(USDT) | OI变化% | 价格变化% |\n")
|
||||
sb.WriteString("### OI Increase Ranking\n")
|
||||
sb.WriteString("Capital inflow, trend continuation or new position signal:\n\n")
|
||||
sb.WriteString("| Rank | Symbol | OI Change(USDT) | OI Change% | Price Change% |\n")
|
||||
sb.WriteString("|------|------|----------------|---------|----------|\n")
|
||||
for _, pos := range data.TopPositions {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n",
|
||||
@@ -185,9 +185,9 @@ func formatOIRankingZH(data *OIRankingData) string {
|
||||
}
|
||||
|
||||
if len(data.LowPositions) > 0 {
|
||||
sb.WriteString("### 持仓减少榜\n")
|
||||
sb.WriteString("资金流出,趋势反转或仓位平仓信号:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 持仓变化(USDT) | OI变化% | 价格变化% |\n")
|
||||
sb.WriteString("### OI Decrease Ranking\n")
|
||||
sb.WriteString("Capital outflow, trend reversal or position close signal:\n\n")
|
||||
sb.WriteString("| Rank | Symbol | OI Change(USDT) | OI Change% | Price Change% |\n")
|
||||
sb.WriteString("|------|------|----------------|---------|----------|\n")
|
||||
for _, pos := range data.LowPositions {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n",
|
||||
@@ -197,7 +197,7 @@ func formatOIRankingZH(data *OIRankingData) string {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("**解读**: OI增+价涨=多头主导 | OI增+价跌=空头主导 | OI减+价涨=空头平仓 | OI减+价跌=多头平仓\n\n")
|
||||
sb.WriteString("**Interpretation**: OI up + Price up = longs dominant | OI up + Price down = shorts dominant | OI down + Price up = shorts closing | OI down + Price down = longs closing\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ func FormatPriceRankingForAI(data *PriceRankingData, lang Language) string {
|
||||
func formatPriceRankingZH(data *PriceRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## 涨跌幅排行\n\n")
|
||||
sb.WriteString("## Price Change Ranking\n\n")
|
||||
|
||||
durationOrder := []string{"1h", "4h", "24h"}
|
||||
for _, duration := range durationOrder {
|
||||
@@ -107,11 +107,11 @@ func formatPriceRankingZH(data *PriceRankingData) string {
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("### %s 涨跌幅\n\n", duration))
|
||||
sb.WriteString(fmt.Sprintf("### %s Price Change\n\n", duration))
|
||||
|
||||
if len(durationData.Top) > 0 {
|
||||
sb.WriteString("**涨幅榜**\n")
|
||||
sb.WriteString("| 币种 | 涨幅 | 价格 | 资金流 | OI变化 |\n")
|
||||
sb.WriteString("**Gainers**\n")
|
||||
sb.WriteString("| Symbol | Gain | Price | Net Flow | OI Change |\n")
|
||||
sb.WriteString("|------|------|------|--------|--------|\n")
|
||||
for _, item := range durationData.Top {
|
||||
sb.WriteString(fmt.Sprintf("| %s | %+.2f%% | $%.4f | %s | %s |\n",
|
||||
@@ -122,8 +122,8 @@ func formatPriceRankingZH(data *PriceRankingData) string {
|
||||
}
|
||||
|
||||
if len(durationData.Low) > 0 {
|
||||
sb.WriteString("**跌幅榜**\n")
|
||||
sb.WriteString("| 币种 | 跌幅 | 价格 | 资金流 | OI变化 |\n")
|
||||
sb.WriteString("**Losers**\n")
|
||||
sb.WriteString("| Symbol | Loss | Price | Net Flow | OI Change |\n")
|
||||
sb.WriteString("|------|------|------|--------|--------|\n")
|
||||
for _, item := range durationData.Low {
|
||||
sb.WriteString(fmt.Sprintf("| %s | %.2f%% | $%.4f | %s | %s |\n",
|
||||
@@ -134,7 +134,7 @@ func formatPriceRankingZH(data *PriceRankingData) string {
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("**解读**: 涨幅大+资金流入+OI增加=强势上涨 | 跌幅大+资金流出+OI减少=弱势下跌\n\n")
|
||||
sb.WriteString("**Interpretation**: Large gain + capital inflow + OI increase = strong uptrend | Large loss + capital outflow + OI decrease = weak downtrend\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
|
||||
979
provider/vergex/client.go
Normal file
979
provider/vergex/client.go
Normal file
@@ -0,0 +1,979 @@
|
||||
package vergex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"nofx/mcp"
|
||||
"nofx/mcp/payment"
|
||||
"nofx/provider/hyperliquid"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultBaseURL = "https://claw402.ai"
|
||||
DefaultChain = "mainnet"
|
||||
DefaultMarketType = "hip3_perp"
|
||||
MaxSignalRankingItems = 30
|
||||
SignalRankingPath = "/api/v1/vergex/signal-ranking"
|
||||
SignalLabPath = "/api/v1/vergex/signal-lab"
|
||||
CostLiquidationHeatmapPath = "/api/v1/vergex/cost-liquidation-heatmap"
|
||||
FlowMarketsPath = "/api/v1/vergex/flow-markets"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
privateKey *ecdsa.PrivateKey
|
||||
httpClient *http.Client
|
||||
logger mcp.Logger
|
||||
}
|
||||
|
||||
type Query struct {
|
||||
MarketType string
|
||||
Symbol string
|
||||
Chain string
|
||||
LiqBand string
|
||||
Category string
|
||||
}
|
||||
|
||||
type SignalRankingData struct {
|
||||
Raw json.RawMessage `json:"raw"`
|
||||
Items []SignalRankItem `json:"items"`
|
||||
}
|
||||
|
||||
type SignalRankItem struct {
|
||||
Rank int `json:"rank,omitempty"`
|
||||
Symbol string `json:"symbol"`
|
||||
MarketType string `json:"market_type,omitempty"`
|
||||
Bias string `json:"bias,omitempty"`
|
||||
Confidence float64 `json:"confidence,omitempty"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Raw json.RawMessage `json:"raw,omitempty"`
|
||||
}
|
||||
|
||||
type MarketAnalysis struct {
|
||||
Symbol string `json:"symbol"`
|
||||
QuerySymbol string `json:"query_symbol"`
|
||||
MarketType string `json:"market_type"`
|
||||
Ranking *SignalRankItem `json:"ranking,omitempty"`
|
||||
SignalLab json.RawMessage `json:"signal_lab,omitempty"`
|
||||
SignalLabError string `json:"signal_lab_error,omitempty"`
|
||||
Heatmap json.RawMessage `json:"heatmap,omitempty"`
|
||||
HeatmapError string `json:"heatmap_error,omitempty"`
|
||||
}
|
||||
|
||||
func NewClient(baseURL, privateKeyHex string, logger mcp.Logger) (*Client, error) {
|
||||
if baseURL == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
if privateKeyHex == "" {
|
||||
privateKeyHex = os.Getenv("CLAW402_WALLET_KEY")
|
||||
}
|
||||
if privateKeyHex == "" {
|
||||
return nil, fmt.Errorf("claw402 wallet private key not set")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = mcp.NewNoopLogger()
|
||||
}
|
||||
|
||||
hexKey := strings.TrimPrefix(strings.TrimSpace(privateKeyHex), "0x")
|
||||
pk, err := crypto.HexToECDSA(hexKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid claw402 private key: %w", err)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
privateKey: pk,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetSignalRanking(ctx context.Context, q Query) (*SignalRankingData, error) {
|
||||
params := url.Values{}
|
||||
addQueryDefaults(params, q, false)
|
||||
body, err := c.doGET(ctx, SignalRankingPath, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseSignalRanking(body)
|
||||
}
|
||||
|
||||
func (c *Client) GetSignalLab(ctx context.Context, q Query) (json.RawMessage, error) {
|
||||
if strings.TrimSpace(q.MarketType) == "" || strings.TrimSpace(q.Symbol) == "" {
|
||||
return nil, fmt.Errorf("marketType and symbol are required")
|
||||
}
|
||||
params := url.Values{}
|
||||
addQueryDefaults(params, q, true)
|
||||
return c.doGET(ctx, SignalLabPath, params)
|
||||
}
|
||||
|
||||
func (c *Client) GetCostLiquidationHeatmap(ctx context.Context, q Query) (json.RawMessage, error) {
|
||||
if strings.TrimSpace(q.MarketType) == "" || strings.TrimSpace(q.Symbol) == "" {
|
||||
return nil, fmt.Errorf("marketType and symbol are required")
|
||||
}
|
||||
params := url.Values{}
|
||||
addQueryDefaults(params, q, true)
|
||||
return c.doGET(ctx, CostLiquidationHeatmapPath, params)
|
||||
}
|
||||
|
||||
// GetFlowMarkets fetches the Vergex net-flow market ranking via the paid
|
||||
// claw402 x402 endpoint. Params mirror the public API: chain (e.g. "mainnet"),
|
||||
// window (e.g. "1h"), and limit. The raw JSON is returned for the caller to
|
||||
// pass through — the response shape is owned by Vergex.
|
||||
func (c *Client) GetFlowMarkets(ctx context.Context, chain, window string, limit int) (json.RawMessage, error) {
|
||||
params := url.Values{}
|
||||
if v := strings.TrimSpace(chain); v != "" {
|
||||
params.Set("chain", v)
|
||||
}
|
||||
if v := strings.TrimSpace(window); v != "" {
|
||||
params.Set("window", v)
|
||||
}
|
||||
if limit > 0 {
|
||||
params.Set("limit", fmt.Sprintf("%d", limit))
|
||||
}
|
||||
return c.doGET(ctx, FlowMarketsPath, params)
|
||||
}
|
||||
|
||||
func addQueryDefaults(params url.Values, q Query, includeMarket bool) {
|
||||
if includeMarket {
|
||||
if q.MarketType != "" {
|
||||
params.Set("marketType", q.MarketType)
|
||||
}
|
||||
if q.Symbol != "" {
|
||||
params.Set("symbol", MarketSymbol(q.MarketType, q.Symbol))
|
||||
}
|
||||
}
|
||||
if q.Chain != "" {
|
||||
params.Set("chain", QueryChain(q.Chain))
|
||||
}
|
||||
if q.LiqBand != "" {
|
||||
params.Set("liqBand", q.LiqBand)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) doGET(ctx context.Context, path string, params url.Values) ([]byte, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("vergex client is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
fullURL := c.baseURL + path
|
||||
if encoded := params.Encode(); encoded != "" {
|
||||
fullURL += "?" + encoded
|
||||
}
|
||||
|
||||
buildReq := func() (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Client-ID", "nofx")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
body, err := payment.DoX402Request(
|
||||
ctx,
|
||||
c.httpClient,
|
||||
buildReq,
|
||||
payment.MakeClaw402SignFunc(c.privateKey),
|
||||
"claw402-vergex",
|
||||
c.logger,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("vergex request failed (%s): %w", path, err)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func ParseSignalRanking(body []byte) (*SignalRankingData, error) {
|
||||
raw := json.RawMessage(append([]byte(nil), body...))
|
||||
var decoded any
|
||||
if err := json.Unmarshal(body, &decoded); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse vergex signal-ranking response: %w", err)
|
||||
}
|
||||
|
||||
rows := findObjectArray(decoded)
|
||||
items := make([]SignalRankItem, 0, len(rows))
|
||||
for idx, row := range rows {
|
||||
obj, ok := row.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item, ok := parseRankItem(obj, idx+1)
|
||||
if ok {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
|
||||
return &SignalRankingData{Raw: raw, Items: items}, nil
|
||||
}
|
||||
|
||||
func FilterTradFiItems(items []SignalRankItem, marketType string, limit int) []SignalRankItem {
|
||||
if marketType == "" {
|
||||
marketType = DefaultMarketType
|
||||
}
|
||||
return filterSignalRankingItems(items, marketType, limit, false)
|
||||
}
|
||||
|
||||
func FilterSignalRankingItems(items []SignalRankItem, marketType string, limit int) []SignalRankItem {
|
||||
return filterSignalRankingItems(items, marketType, limit, true)
|
||||
}
|
||||
|
||||
func filterSignalRankingItems(items []SignalRankItem, marketType string, limit int, allowAll bool) []SignalRankItem {
|
||||
requestedMarketType := marketType
|
||||
normalizedMarketType := normalizeMarketType(marketType)
|
||||
includeAll := allowAll && isAllMarketType(marketType)
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
if limit > MaxSignalRankingItems {
|
||||
limit = MaxSignalRankingItems
|
||||
}
|
||||
|
||||
out := make([]SignalRankItem, 0, limit)
|
||||
seen := make(map[string]bool)
|
||||
for _, item := range items {
|
||||
base := QuerySymbol(item.Symbol)
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
itemMarket := normalizeMarketType(item.MarketType)
|
||||
isXYZ := hyperliquid.IsXYZAsset(item.Symbol) || hyperliquid.IsXYZAsset(base)
|
||||
if !includeAll {
|
||||
if itemMarket != "" && normalizedMarketType != "" && itemMarket != normalizedMarketType && !isTradeFiMarketType(itemMarket) && !isXYZ {
|
||||
continue
|
||||
}
|
||||
if itemMarket == "" && !isXYZ {
|
||||
continue
|
||||
}
|
||||
}
|
||||
item.MarketType = coalesce(item.MarketType, inferRankingMarketType(item.Symbol, base, requestedMarketType))
|
||||
tradeSymbol := TradableSymbolForMarket(item.MarketType, item.Symbol)
|
||||
if tradeSymbol == "" || seen[tradeSymbol] {
|
||||
continue
|
||||
}
|
||||
item.Symbol = base
|
||||
item.Category = rankingCategory(item.MarketType, base)
|
||||
out = append(out, item)
|
||||
seen[tradeSymbol] = true
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TradableSymbol(symbol string) string {
|
||||
return TradableSymbolForMarket(DefaultMarketType, symbol)
|
||||
}
|
||||
|
||||
func TradableSymbolForMarket(marketType, symbol string) string {
|
||||
base := QuerySymbol(symbol)
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
if isCoreMarketType(marketType) {
|
||||
return base
|
||||
}
|
||||
if isAllMarketType(marketType) && !hyperliquid.IsXYZAsset(symbol) && !hyperliquid.IsXYZAsset(base) {
|
||||
return base
|
||||
}
|
||||
return hyperliquid.FormatCoinForAPI("xyz:" + base)
|
||||
}
|
||||
|
||||
func MarketSymbol(marketType, symbol string) string {
|
||||
symbol = strings.TrimSpace(symbol)
|
||||
if symbol == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(symbol, "/") {
|
||||
parts := strings.Split(symbol, "/")
|
||||
symbol = parts[len(parts)-1]
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(symbol), "xyz:") {
|
||||
return "xyz:" + hyperliquid.NormalizeCoinBase(strings.TrimPrefix(strings.ToUpper(symbol), "XYZ:"))
|
||||
}
|
||||
base := QuerySymbol(symbol)
|
||||
if base == "" {
|
||||
return ""
|
||||
}
|
||||
if normalizeMarketType(marketType) == "hip3perp" {
|
||||
return "xyz:" + base
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func QuerySymbol(symbol string) string {
|
||||
symbol = strings.TrimSpace(symbol)
|
||||
if symbol == "" {
|
||||
return ""
|
||||
}
|
||||
symbol = strings.TrimPrefix(strings.ToUpper(symbol), "XYZ:")
|
||||
if strings.Contains(symbol, "/") {
|
||||
parts := strings.Split(symbol, "/")
|
||||
symbol = parts[len(parts)-1]
|
||||
}
|
||||
return hyperliquid.NormalizeCoinBase(symbol)
|
||||
}
|
||||
|
||||
func QueryChain(chain string) string {
|
||||
raw := strings.TrimSpace(chain)
|
||||
normalized := strings.ToLower(raw)
|
||||
switch normalized {
|
||||
case "", "hyperliquid", "hl":
|
||||
return DefaultChain
|
||||
default:
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
func FormatAnalysisForAI(analysis *MarketAnalysis) string {
|
||||
if analysis == nil {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("### %s (Vergex %s/%s)\n", analysis.Symbol, analysis.MarketType, analysis.QuerySymbol))
|
||||
if analysis.Ranking != nil {
|
||||
sb.WriteString(fmt.Sprintf("Ranking: rank=%d bias=%s confidence=%.2f score=%.4f category=%s\n",
|
||||
analysis.Ranking.Rank,
|
||||
emptyDash(analysis.Ranking.Bias),
|
||||
analysis.Ranking.Confidence,
|
||||
analysis.Ranking.Score,
|
||||
emptyDash(analysis.Ranking.Category)))
|
||||
}
|
||||
if len(analysis.SignalLab) > 0 {
|
||||
sb.WriteString("#### Signal Lab\n")
|
||||
sb.WriteString(FormatSignalLabMarkdown(analysis.SignalLab))
|
||||
sb.WriteString("\n")
|
||||
} else if analysis.SignalLabError != "" {
|
||||
sb.WriteString("Signal Lab: unavailable (")
|
||||
sb.WriteString(truncateText(analysis.SignalLabError, 360))
|
||||
sb.WriteString(")\n")
|
||||
}
|
||||
if len(analysis.Heatmap) > 0 {
|
||||
sb.WriteString("#### Cost/Liquidation Heatmap\n")
|
||||
sb.WriteString(FormatHeatmapMarkdown(analysis.Heatmap))
|
||||
sb.WriteString("\n")
|
||||
} else if analysis.HeatmapError != "" {
|
||||
sb.WriteString("Cost/Liquidation Heatmap: unavailable (")
|
||||
sb.WriteString(truncateText(analysis.HeatmapError, 360))
|
||||
sb.WriteString(")\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func FormatSignalLabMarkdown(raw json.RawMessage) string {
|
||||
data, ok := decodeVergexDataObject(raw)
|
||||
if !ok {
|
||||
return fallbackJSONBlock(raw, 2200)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
writeScalarSummary(&sb, data, []string{"symbol", "marketType", "band", "bias", "confidence", "compositeZ", "score"})
|
||||
|
||||
dimensions := objectArray(data, "dimensions")
|
||||
if len(dimensions) == 0 {
|
||||
return withFallbackIfEmpty(sb.String(), raw)
|
||||
}
|
||||
|
||||
sb.WriteString("| Family | Signal | Direction | Strength | Percentile | Detail |\n")
|
||||
sb.WriteString("| --- | --- | --- | --- | ---: | --- |\n")
|
||||
limit := minInt(len(dimensions), 8)
|
||||
for _, row := range dimensions[:limit] {
|
||||
sb.WriteString("| ")
|
||||
sb.WriteString(markdownCell(firstString(row, "family")))
|
||||
sb.WriteString(" | ")
|
||||
sb.WriteString(markdownCell(firstString(row, "label", "key")))
|
||||
sb.WriteString(" | ")
|
||||
sb.WriteString(markdownCell(firstString(row, "direction")))
|
||||
sb.WriteString(" | ")
|
||||
sb.WriteString(markdownCell(firstString(row, "strength")))
|
||||
sb.WriteString(" | ")
|
||||
sb.WriteString(markdownCell(formatOptionalFloat(row, "percentile")))
|
||||
sb.WriteString(" | ")
|
||||
sb.WriteString(markdownCell(truncateText(firstString(row, "detail", "what"), 220)))
|
||||
sb.WriteString(" |\n")
|
||||
}
|
||||
if len(dimensions) > limit {
|
||||
sb.WriteString(fmt.Sprintf("- Additional dimensions omitted: %d\n", len(dimensions)-limit))
|
||||
}
|
||||
return withFallbackIfEmpty(sb.String(), raw)
|
||||
}
|
||||
|
||||
func FormatHeatmapMarkdown(raw json.RawMessage) string {
|
||||
data, ok := decodeVergexDataObject(raw)
|
||||
if !ok {
|
||||
return fallbackJSONBlock(raw, 2600)
|
||||
}
|
||||
|
||||
bins := objectArray(data, "bins")
|
||||
if len(bins) == 0 {
|
||||
var sb strings.Builder
|
||||
writeScalarSummary(&sb, data, []string{"symbol", "marketType", "band", "liqBand", "currentPrice", "price", "binStep"})
|
||||
return withFallbackIfEmpty(sb.String(), raw)
|
||||
}
|
||||
|
||||
zones := make([]heatmapZone, 0, len(bins))
|
||||
var totalLongCost, totalShortCost, totalLongLiq, totalShortLiq float64
|
||||
for _, bin := range bins {
|
||||
zone := heatmapZone{
|
||||
Start: firstFloat(bin, "bucketStartPrice", "start", "startPrice"),
|
||||
End: firstFloat(bin, "bucketEndPrice", "end", "endPrice"),
|
||||
PX: firstFloat(bin, "px", "price"),
|
||||
LongCost: firstFloat(bin, "longCost"),
|
||||
ShortCost: firstFloat(bin, "shortCost"),
|
||||
LongLiq: firstFloat(bin, "longLiq", "longLiquidation"),
|
||||
ShortLiq: firstFloat(bin, "shortLiq", "shortLiquidation"),
|
||||
}
|
||||
totalLongCost += zone.LongCost
|
||||
totalShortCost += zone.ShortCost
|
||||
totalLongLiq += zone.LongLiq
|
||||
totalShortLiq += zone.ShortLiq
|
||||
zone.Score = maxFloat(zone.LongCost, zone.ShortCost, zone.LongLiq, zone.ShortLiq)
|
||||
if zone.Score > 0 {
|
||||
zones = append(zones, zone)
|
||||
}
|
||||
}
|
||||
sortHeatmapZones(zones)
|
||||
|
||||
var sb strings.Builder
|
||||
writeScalarSummary(&sb, data, []string{"symbol", "marketType", "band", "liqBand", "currentPrice", "price", "binStep"})
|
||||
sb.WriteString(fmt.Sprintf("- Total cost: long %s / short %s\n", formatUSDAmount(totalLongCost), formatUSDAmount(totalShortCost)))
|
||||
sb.WriteString(fmt.Sprintf("- Total liquidation: long %s / short %s\n", formatUSDAmount(totalLongLiq), formatUSDAmount(totalShortLiq)))
|
||||
sb.WriteString("| Price zone | Long cost | Short cost | Long liq | Short liq | Main cluster |\n")
|
||||
sb.WriteString("| --- | ---: | ---: | ---: | ---: | --- |\n")
|
||||
limit := minInt(len(zones), 10)
|
||||
for _, zone := range zones[:limit] {
|
||||
sb.WriteString("| ")
|
||||
sb.WriteString(markdownCell(formatPriceZone(zone)))
|
||||
sb.WriteString(" | ")
|
||||
sb.WriteString(markdownCell(formatUSDAmount(zone.LongCost)))
|
||||
sb.WriteString(" | ")
|
||||
sb.WriteString(markdownCell(formatUSDAmount(zone.ShortCost)))
|
||||
sb.WriteString(" | ")
|
||||
sb.WriteString(markdownCell(formatUSDAmount(zone.LongLiq)))
|
||||
sb.WriteString(" | ")
|
||||
sb.WriteString(markdownCell(formatUSDAmount(zone.ShortLiq)))
|
||||
sb.WriteString(" | ")
|
||||
sb.WriteString(markdownCell(zone.MainCluster()))
|
||||
sb.WriteString(" |\n")
|
||||
}
|
||||
if len(zones) > limit {
|
||||
sb.WriteString(fmt.Sprintf("- Additional heatmap bins omitted: %d\n", len(zones)-limit))
|
||||
}
|
||||
return withFallbackIfEmpty(sb.String(), raw)
|
||||
}
|
||||
|
||||
type heatmapZone struct {
|
||||
Start float64
|
||||
End float64
|
||||
PX float64
|
||||
LongCost float64
|
||||
ShortCost float64
|
||||
LongLiq float64
|
||||
ShortLiq float64
|
||||
Score float64
|
||||
}
|
||||
|
||||
func (z heatmapZone) MainCluster() string {
|
||||
maxVal := maxFloat(z.LongCost, z.ShortCost, z.LongLiq, z.ShortLiq)
|
||||
switch maxVal {
|
||||
case z.LongCost:
|
||||
return "long cost"
|
||||
case z.ShortCost:
|
||||
return "short cost"
|
||||
case z.LongLiq:
|
||||
return "long liquidation"
|
||||
case z.ShortLiq:
|
||||
return "short liquidation"
|
||||
default:
|
||||
return "-"
|
||||
}
|
||||
}
|
||||
|
||||
func sortHeatmapZones(zones []heatmapZone) {
|
||||
sort.SliceStable(zones, func(i, j int) bool {
|
||||
return zones[i].Score > zones[j].Score
|
||||
})
|
||||
}
|
||||
|
||||
func decodeVergexDataObject(raw json.RawMessage) (map[string]any, bool) {
|
||||
var decoded any
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
obj, ok := decoded.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if data, ok := lookupNormalized(obj, "data"); ok {
|
||||
if dataObj, ok := data.(map[string]any); ok {
|
||||
return dataObj, true
|
||||
}
|
||||
}
|
||||
return obj, true
|
||||
}
|
||||
|
||||
func writeScalarSummary(sb *strings.Builder, obj map[string]any, keys []string) {
|
||||
wrote := false
|
||||
for _, key := range keys {
|
||||
value, ok := lookupNormalized(obj, key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
text := formatScalarValue(value)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- %s: %s\n", titleKey(key), text))
|
||||
wrote = true
|
||||
}
|
||||
if wrote {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
func objectArray(obj map[string]any, key string) []map[string]any {
|
||||
val, ok := lookupNormalized(obj, key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rows, ok := val.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if rowObj, ok := row.(map[string]any); ok {
|
||||
out = append(out, rowObj)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func formatOptionalFloat(obj map[string]any, key string) string {
|
||||
val, ok := lookupNormalized(obj, key)
|
||||
if !ok {
|
||||
return "-"
|
||||
}
|
||||
num, ok := anyFloat(val)
|
||||
if !ok {
|
||||
return formatScalarValue(val)
|
||||
}
|
||||
return trimFloat(num, 1)
|
||||
}
|
||||
|
||||
func anyFloat(val any) (float64, bool) {
|
||||
switch t := val.(type) {
|
||||
case float64:
|
||||
return t, true
|
||||
case float32:
|
||||
return float64(t), true
|
||||
case int:
|
||||
return float64(t), true
|
||||
case int64:
|
||||
return float64(t), true
|
||||
case json.Number:
|
||||
f, err := t.Float64()
|
||||
return f, err == nil
|
||||
case string:
|
||||
var f float64
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(t), "%f", &f); err == nil {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func formatScalarValue(val any) string {
|
||||
switch t := val.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(t)
|
||||
case bool:
|
||||
return fmt.Sprintf("%t", t)
|
||||
case float64:
|
||||
return trimFloat(t, 4)
|
||||
case json.Number:
|
||||
f, err := t.Float64()
|
||||
if err == nil {
|
||||
return trimFloat(f, 4)
|
||||
}
|
||||
return t.String()
|
||||
default:
|
||||
if f, ok := anyFloat(val); ok {
|
||||
return trimFloat(f, 4)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func formatPriceZone(z heatmapZone) string {
|
||||
if z.Start != 0 || z.End != 0 {
|
||||
return fmt.Sprintf("%s-%s", trimFloat(z.Start, 4), trimFloat(z.End, 4))
|
||||
}
|
||||
if z.PX != 0 {
|
||||
return trimFloat(z.PX, 4)
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
|
||||
func formatUSDAmount(v float64) string {
|
||||
abs := math.Abs(v)
|
||||
sign := ""
|
||||
if v < 0 {
|
||||
sign = "-"
|
||||
}
|
||||
switch {
|
||||
case abs >= 1_000_000_000:
|
||||
return fmt.Sprintf("%s$%.2fB", sign, abs/1_000_000_000)
|
||||
case abs >= 1_000_000:
|
||||
return fmt.Sprintf("%s$%.2fM", sign, abs/1_000_000)
|
||||
case abs >= 1_000:
|
||||
return fmt.Sprintf("%s$%.2fK", sign, abs/1_000)
|
||||
default:
|
||||
return fmt.Sprintf("%s$%.2f", sign, abs)
|
||||
}
|
||||
}
|
||||
|
||||
func trimFloat(v float64, precision int) string {
|
||||
text := fmt.Sprintf("%.*f", precision, v)
|
||||
text = strings.TrimRight(text, "0")
|
||||
text = strings.TrimRight(text, ".")
|
||||
if text == "-0" {
|
||||
return "0"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func markdownCell(text string) string {
|
||||
text = strings.ReplaceAll(strings.TrimSpace(text), "\n", " ")
|
||||
text = strings.ReplaceAll(text, "|", "\\|")
|
||||
if text == "" {
|
||||
return "-"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func titleKey(key string) string {
|
||||
switch key {
|
||||
case "marketType":
|
||||
return "Market type"
|
||||
case "liqBand":
|
||||
return "Liquidation band"
|
||||
case "currentPrice":
|
||||
return "Current price"
|
||||
case "binStep":
|
||||
return "Bin step"
|
||||
case "compositeZ":
|
||||
return "Composite Z"
|
||||
default:
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(key[:1]) + key[1:]
|
||||
}
|
||||
}
|
||||
|
||||
func withFallbackIfEmpty(text string, raw json.RawMessage) string {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return fallbackJSONBlock(raw, 2200)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func fallbackJSONBlock(raw json.RawMessage, maxBytes int) string {
|
||||
return "```json\n" + CompactJSON(raw, maxBytes) + "\n```\n"
|
||||
}
|
||||
|
||||
func maxFloat(values ...float64) float64 {
|
||||
max := 0.0
|
||||
for _, value := range values {
|
||||
if value > max {
|
||||
max = value
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func CompactJSON(raw json.RawMessage, maxBytes int) string {
|
||||
if len(raw) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
var buf any
|
||||
if err := json.Unmarshal(raw, &buf); err == nil {
|
||||
if compact, err := json.Marshal(buf); err == nil {
|
||||
raw = compact
|
||||
}
|
||||
}
|
||||
text := string(raw)
|
||||
if maxBytes > 0 && len(text) > maxBytes {
|
||||
return text[:maxBytes] + "...<truncated>"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func truncateText(text string, maxBytes int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if maxBytes <= 0 || len(text) <= maxBytes {
|
||||
return text
|
||||
}
|
||||
return text[:maxBytes] + "...<truncated>"
|
||||
}
|
||||
|
||||
func parseRankItem(obj map[string]any, fallbackRank int) (SignalRankItem, bool) {
|
||||
symbol := firstString(obj, "symbol", "ticker", "base", "coin", "asset", "market", "name")
|
||||
if symbol == "" {
|
||||
symbol = nestedMarketString(obj, "symbol", "ticker", "base", "coin", "asset", "name")
|
||||
}
|
||||
if symbol == "" {
|
||||
return SignalRankItem{}, false
|
||||
}
|
||||
raw, _ := json.Marshal(obj)
|
||||
rank := firstInt(obj, "rank", "ranking", "position")
|
||||
if rank <= 0 {
|
||||
rank = fallbackRank
|
||||
}
|
||||
score := firstFloat(obj, "compositeZ", "composite_z", "score", "rank_score", "z", "value")
|
||||
confidence := firstFloat(obj, "confidence", "conf", "signalConfidence", "signal_confidence")
|
||||
marketType := firstString(obj, "marketType", "market_type", "venue")
|
||||
if marketType == "" {
|
||||
marketType = nestedMarketString(obj, "marketType", "market_type", "venue", "type")
|
||||
}
|
||||
item := SignalRankItem{
|
||||
Rank: rank,
|
||||
Symbol: QuerySymbol(symbol),
|
||||
MarketType: marketType,
|
||||
Bias: firstString(obj, "bias", "direction", "side", "signal"),
|
||||
Confidence: confidence,
|
||||
Score: score,
|
||||
Raw: raw,
|
||||
}
|
||||
if item.Symbol != "" {
|
||||
item.Category = hyperliquid.XYZCategory(item.Symbol)
|
||||
}
|
||||
return item, item.Symbol != ""
|
||||
}
|
||||
|
||||
func nestedMarketString(obj map[string]any, keys ...string) string {
|
||||
val, ok := lookupNormalized(obj, "market")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
nested, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return firstString(nested, keys...)
|
||||
}
|
||||
|
||||
func findObjectArray(v any) []any {
|
||||
switch t := v.(type) {
|
||||
case []any:
|
||||
if arrayLooksLikeRows(t) {
|
||||
return t
|
||||
}
|
||||
for _, item := range t {
|
||||
if rows := findObjectArray(item); len(rows) > 0 {
|
||||
return rows
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
for _, key := range []string{"data", "items", "results", "ranking", "rankings", "rows", "markets", "signals"} {
|
||||
if val, ok := lookupNormalized(t, key); ok {
|
||||
if rows := findObjectArray(val); len(rows) > 0 {
|
||||
return rows
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, val := range t {
|
||||
if rows := findObjectArray(val); len(rows) > 0 {
|
||||
return rows
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func arrayLooksLikeRows(rows []any) bool {
|
||||
for _, row := range rows {
|
||||
obj, ok := row.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if firstString(obj, "symbol", "ticker", "base", "coin", "asset", "market", "name") != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func firstString(obj map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
val, ok := lookupNormalized(obj, key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch t := val.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(t) != "" {
|
||||
return strings.TrimSpace(t)
|
||||
}
|
||||
case fmt.Stringer:
|
||||
if strings.TrimSpace(t.String()) != "" {
|
||||
return strings.TrimSpace(t.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstFloat(obj map[string]any, keys ...string) float64 {
|
||||
for _, key := range keys {
|
||||
val, ok := lookupNormalized(obj, key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch t := val.(type) {
|
||||
case float64:
|
||||
return t
|
||||
case int:
|
||||
return float64(t)
|
||||
case json.Number:
|
||||
f, _ := t.Float64()
|
||||
return f
|
||||
case string:
|
||||
var f float64
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(t), "%f", &f); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstInt(obj map[string]any, keys ...string) int {
|
||||
for _, key := range keys {
|
||||
val, ok := lookupNormalized(obj, key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch t := val.(type) {
|
||||
case float64:
|
||||
return int(t)
|
||||
case int:
|
||||
return t
|
||||
case json.Number:
|
||||
i, _ := t.Int64()
|
||||
return int(i)
|
||||
case string:
|
||||
var i int
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(t), "%d", &i); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func lookupNormalized(obj map[string]any, key string) (any, bool) {
|
||||
want := normalizeKey(key)
|
||||
for k, v := range obj {
|
||||
if normalizeKey(k) == want {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func normalizeKey(key string) string {
|
||||
replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "")
|
||||
return replacer.Replace(strings.ToLower(strings.TrimSpace(key)))
|
||||
}
|
||||
|
||||
func normalizeMarketType(marketType string) string {
|
||||
replacer := strings.NewReplacer("_", "", "-", "", " ", "", ".", "", "/", "")
|
||||
return replacer.Replace(strings.ToLower(strings.TrimSpace(marketType)))
|
||||
}
|
||||
|
||||
func isTradeFiMarketType(marketType string) bool {
|
||||
switch normalizeMarketType(marketType) {
|
||||
case "hip3perp", "hip3", "xyz", "xyzperp", "tradefi", "tradfi",
|
||||
"stock", "stocks", "equity", "equities", "usequity", "usequities", "usstock", "usstocks",
|
||||
"commodity", "commodities", "forex", "fx", "index", "indices", "preipo":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isAllMarketType(marketType string) bool {
|
||||
switch normalizeMarketType(marketType) {
|
||||
case "", "all", "any", "ranking", "signalranking", "claw402", "vergex":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isCoreMarketType(marketType string) bool {
|
||||
switch normalizeMarketType(marketType) {
|
||||
case "coreperp", "core", "crypto", "cryptoperp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func inferRankingMarketType(symbol, base, fallback string) string {
|
||||
if !isAllMarketType(fallback) && strings.TrimSpace(fallback) != "" {
|
||||
return fallback
|
||||
}
|
||||
if hyperliquid.IsXYZAsset(symbol) || hyperliquid.IsXYZAsset(base) {
|
||||
return DefaultMarketType
|
||||
}
|
||||
return "core_perp"
|
||||
}
|
||||
|
||||
func rankingCategory(marketType, base string) string {
|
||||
if isCoreMarketType(marketType) {
|
||||
return "crypto"
|
||||
}
|
||||
return hyperliquid.XYZCategory(base)
|
||||
}
|
||||
|
||||
func coalesce(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func emptyDash(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "-"
|
||||
}
|
||||
return value
|
||||
}
|
||||
220
provider/vergex/client_test.go
Normal file
220
provider/vergex/client_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
package vergex
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSignalRankingAndFilterTradFiItems(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"data": {
|
||||
"rankings": [
|
||||
{"marketType":"hip3-perp","symbol":"AAPL","bias":"long","confidence":0.88,"compositeZ":1.75},
|
||||
{"marketType":"stock","symbol":"NVDA","bias":"long","confidence":0.81,"compositeZ":1.25},
|
||||
{"market_type":"core_perp","symbol":"BTC","bias":"short","score":0.91}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
|
||||
ranking, err := ParseSignalRanking(body)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSignalRanking returned error: %v", err)
|
||||
}
|
||||
if len(ranking.Items) != 3 {
|
||||
t.Fatalf("items len = %d, want 3", len(ranking.Items))
|
||||
}
|
||||
if ranking.Items[0].Symbol != "AAPL" || ranking.Items[0].MarketType != "hip3-perp" || ranking.Items[0].Bias != "long" {
|
||||
t.Fatalf("unexpected first item: %+v", ranking.Items[0])
|
||||
}
|
||||
|
||||
items := FilterTradFiItems(ranking.Items, "hip3_perp", 5)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("filtered len = %d, want 2", len(items))
|
||||
}
|
||||
if got := TradableSymbol(items[0].Symbol); got != "xyz:AAPL" {
|
||||
t.Fatalf("TradableSymbol = %q, want xyz:AAPL", got)
|
||||
}
|
||||
if got := TradableSymbol(items[1].Symbol); got != "xyz:NVDA" {
|
||||
t.Fatalf("TradableSymbol = %q, want xyz:NVDA", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterTradFiItemsAllowsFullClaw402Board(t *testing.T) {
|
||||
items := make([]SignalRankItem, 0, 35)
|
||||
for i := 1; i <= 35; i++ {
|
||||
items = append(items, SignalRankItem{
|
||||
Rank: i,
|
||||
Symbol: fmt.Sprintf("xyz:STK%02d", i),
|
||||
MarketType: "hip3_perp",
|
||||
Bias: "bullish",
|
||||
})
|
||||
}
|
||||
|
||||
filtered := FilterTradFiItems(items, "hip3_perp", 30)
|
||||
if len(filtered) != 30 {
|
||||
t.Fatalf("filtered len = %d, want 30", len(filtered))
|
||||
}
|
||||
if filtered[0].Symbol != "STK01" || filtered[29].Symbol != "STK30" {
|
||||
t.Fatalf("unexpected filtered bounds: first=%q last=%q", filtered[0].Symbol, filtered[29].Symbol)
|
||||
}
|
||||
|
||||
capped := FilterTradFiItems(items, "hip3_perp", 35)
|
||||
if len(capped) != MaxSignalRankingItems {
|
||||
t.Fatalf("capped len = %d, want %d", len(capped), MaxSignalRankingItems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSignalRankingReadsNestedMarketShape(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"data": {
|
||||
"items": [
|
||||
{"market":{"marketType":"hip3_perp","symbol":"xyz:NBIS"},"symbol":"xyz:NBIS","bias":"bullish","compositeZ":1.05,"rank":5},
|
||||
{"market":{"marketType":"hip3_perp","symbol":"xyz:DRAM"},"symbol":"xyz:DRAM","bias":"bullish","compositeZ":0.47,"rank":10},
|
||||
{"market":{"marketType":"core_perp","symbol":"BTC"},"symbol":"BTC","bias":"bearish","compositeZ":-0.08,"rank":12}
|
||||
]
|
||||
}
|
||||
}`)
|
||||
|
||||
ranking, err := ParseSignalRanking(body)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSignalRanking returned error: %v", err)
|
||||
}
|
||||
allItems := FilterSignalRankingItems(ranking.Items, "all", 30)
|
||||
if len(allItems) != 3 {
|
||||
t.Fatalf("all filtered len = %d, want 3: %+v", len(allItems), allItems)
|
||||
}
|
||||
if allItems[2].Symbol != "BTC" || allItems[2].MarketType != "core_perp" || allItems[2].Category != "crypto" {
|
||||
t.Fatalf("unexpected crypto item: %+v", allItems[2])
|
||||
}
|
||||
items := FilterTradFiItems(ranking.Items, "hip3_perp", 30)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("filtered len = %d, want 2: %+v", len(items), items)
|
||||
}
|
||||
if items[0].Symbol != "NBIS" || items[0].MarketType != "hip3_perp" {
|
||||
t.Fatalf("unexpected first item: %+v", items[0])
|
||||
}
|
||||
if items[1].Symbol != "DRAM" || items[1].MarketType != "hip3_perp" {
|
||||
t.Fatalf("unexpected second item: %+v", items[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketSymbolPreservesHIP3XYZPrefix(t *testing.T) {
|
||||
if got := MarketSymbol("hip3_perp", "INTC"); got != "xyz:INTC" {
|
||||
t.Fatalf("MarketSymbol hip3_perp/INTC = %q, want xyz:INTC", got)
|
||||
}
|
||||
if got := MarketSymbol("hip3_perp", "xyz:skhx"); got != "xyz:SKHX" {
|
||||
t.Fatalf("MarketSymbol hip3_perp/xyz:skhx = %q, want xyz:SKHX", got)
|
||||
}
|
||||
if got := MarketSymbol("core_perp", "BTC"); got != "BTC" {
|
||||
t.Fatalf("MarketSymbol core_perp/BTC = %q, want BTC", got)
|
||||
}
|
||||
if got := TradableSymbolForMarket("core_perp", "BTC"); got != "BTC" {
|
||||
t.Fatalf("TradableSymbolForMarket core_perp/BTC = %q, want BTC", got)
|
||||
}
|
||||
if got := TradableSymbolForMarket("hip3_perp", "INTC"); got != "xyz:INTC" {
|
||||
t.Fatalf("TradableSymbolForMarket hip3_perp/INTC = %q, want xyz:INTC", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddQueryDefaultsUsesClaw402GatewayParams(t *testing.T) {
|
||||
params := url.Values{}
|
||||
addQueryDefaults(params, Query{
|
||||
MarketType: "hip3_perp",
|
||||
Symbol: "INTC",
|
||||
Chain: "hyperliquid",
|
||||
LiqBand: "15",
|
||||
}, true)
|
||||
|
||||
if got := params.Get("marketType"); got != "hip3_perp" {
|
||||
t.Fatalf("marketType = %q", got)
|
||||
}
|
||||
if got := params.Get("symbol"); got != "xyz:INTC" {
|
||||
t.Fatalf("symbol = %q, want xyz:INTC", got)
|
||||
}
|
||||
if got := params.Get("chain"); got != "mainnet" {
|
||||
t.Fatalf("chain = %q, want mainnet", got)
|
||||
}
|
||||
if got := params.Get("liqBand"); got != "15" {
|
||||
t.Fatalf("liqBand = %q, want 15", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryChainMapsHyperliquidToVergexMainnet(t *testing.T) {
|
||||
if got := QueryChain("hyperliquid"); got != "mainnet" {
|
||||
t.Fatalf("QueryChain hyperliquid = %q, want mainnet", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAnalysisForAIIncludesDetailErrors(t *testing.T) {
|
||||
text := FormatAnalysisForAI(&MarketAnalysis{
|
||||
Symbol: "xyz:NVDA",
|
||||
QuerySymbol: "NVDA",
|
||||
MarketType: "stock",
|
||||
SignalLabError: "upstream returned status 502",
|
||||
HeatmapError: "market not found",
|
||||
})
|
||||
|
||||
if !containsAll(text, "Signal Lab: unavailable", "upstream returned status 502", "Cost/Liquidation Heatmap: unavailable", "market not found") {
|
||||
t.Fatalf("formatted analysis did not include detail errors:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAnalysisForAIFormatsVergexDetailsAsMarkdown(t *testing.T) {
|
||||
text := FormatAnalysisForAI(&MarketAnalysis{
|
||||
Symbol: "xyz:DRAM",
|
||||
QuerySymbol: "DRAM",
|
||||
MarketType: "hip3_perp",
|
||||
SignalLab: []byte(`{
|
||||
"data": {
|
||||
"band": "15",
|
||||
"bias": "bullish",
|
||||
"compositeZ": 1.41,
|
||||
"confidence": "Medium",
|
||||
"dimensions": [
|
||||
{
|
||||
"family": "I Cost & Positioning",
|
||||
"label": "Capital-gains overhang",
|
||||
"direction": "bullish",
|
||||
"strength": "medium",
|
||||
"percentile": 80,
|
||||
"detail": "price is above aggregate cost"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`),
|
||||
Heatmap: []byte(`{
|
||||
"data": {
|
||||
"binStep": 3.2,
|
||||
"bins": [
|
||||
{"bucketStartPrice": 100, "bucketEndPrice": 103.2, "longCost": 1200000, "shortCost": 1000, "longLiq": 5000, "shortLiq": 700000},
|
||||
{"bucketStartPrice": 103.2, "bucketEndPrice": 106.4, "longCost": 1000, "shortCost": 2000, "longLiq": 900000, "shortLiq": 4000}
|
||||
]
|
||||
}
|
||||
}`),
|
||||
})
|
||||
|
||||
if !containsAll(text,
|
||||
"#### Signal Lab",
|
||||
"| Family | Signal | Direction | Strength | Percentile | Detail |",
|
||||
"Capital-gains overhang",
|
||||
"#### Cost/Liquidation Heatmap",
|
||||
"| Price zone | Long cost | Short cost | Long liq | Short liq | Main cluster |",
|
||||
"$1.20M",
|
||||
) {
|
||||
t.Fatalf("formatted analysis is not markdown enough:\n%s", text)
|
||||
}
|
||||
if strings.Contains(text, "Signal Lab: {") || strings.Contains(text, "Cost/Liquidation Heatmap: {") {
|
||||
t.Fatalf("formatted analysis still includes raw inline JSON:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func containsAll(text string, needles ...string) bool {
|
||||
for _, needle := range needles {
|
||||
if !strings.Contains(text, needle) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
59
safe/go.go
Normal file
59
safe/go.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Package safe provides panic-recovery wrappers for goroutines.
|
||||
// A panic in any bare goroutine tears down the entire process.
|
||||
// Use safe.Go instead of `go func()` in long-running or critical paths.
|
||||
package safe
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
// Go launches fn in a new goroutine with automatic panic recovery.
|
||||
// If fn panics, the panic is logged (with stack trace) but the process
|
||||
// continues running. An optional onPanic callback receives the recovered value.
|
||||
func Go(fn func(), onPanic ...func(recovered interface{})) {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
stack := string(debug.Stack())
|
||||
logger.Errorf("🔥 goroutine panic recovered: %v\n%s", r, stack)
|
||||
|
||||
for _, cb := range onPanic {
|
||||
func() {
|
||||
defer func() {
|
||||
if r2 := recover(); r2 != nil {
|
||||
logger.Errorf("🔥 onPanic callback itself panicked: %v", r2)
|
||||
}
|
||||
}()
|
||||
cb(r)
|
||||
}()
|
||||
}
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
|
||||
// GoNamed is like Go but tags the log line with a human-readable name.
|
||||
func GoNamed(name string, fn func(), onPanic ...func(recovered interface{})) {
|
||||
Go(func() {
|
||||
fn()
|
||||
}, append([]func(interface{}){
|
||||
func(r interface{}) {
|
||||
logger.Errorf("🔥 [%s] goroutine panicked: %v", name, r)
|
||||
},
|
||||
}, onPanic...)...)
|
||||
}
|
||||
|
||||
// Must converts a panic into an error. Useful inside goroutines where you
|
||||
// want to handle panics as errors in the caller's recovery flow.
|
||||
func Must(fn func()) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("panic: %v\n%s", r, debug.Stack())
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
return nil
|
||||
}
|
||||
29
safe/io.go
Normal file
29
safe/io.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Package safe provides safe I/O helpers.
|
||||
package safe
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// MaxResponseBody is the default maximum size for HTTP response bodies (10MB).
|
||||
const MaxResponseBody = 10 * 1024 * 1024
|
||||
|
||||
// ReadAllLimited reads all bytes from r up to maxBytes.
|
||||
// If maxBytes <= 0, it defaults to MaxResponseBody (10MB).
|
||||
// Returns an error if the response exceeds the limit.
|
||||
func ReadAllLimited(r io.Reader, maxBytes ...int64) ([]byte, error) {
|
||||
limit := int64(MaxResponseBody)
|
||||
if len(maxBytes) > 0 && maxBytes[0] > 0 {
|
||||
limit = maxBytes[0]
|
||||
}
|
||||
lr := io.LimitReader(r, limit+1)
|
||||
data, err := io.ReadAll(lr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, fmt.Errorf("response body exceeds %d bytes limit", limit)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
BIN
screenshots/demo-cover.png
Normal file
BIN
screenshots/demo-cover.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -22,6 +22,8 @@ func (AICharge) TableName() string { return "ai_charges" }
|
||||
var modelPrices = map[string]float64{
|
||||
"deepseek": 0.003,
|
||||
"deepseek-reasoner": 0.005,
|
||||
"deepseek-v4-flash": 0.003,
|
||||
"deepseek-v4-pro": 0.01,
|
||||
"gpt-5.4": 0.05,
|
||||
"gpt-5.4-pro": 0.50,
|
||||
"gpt-5.3": 0.01,
|
||||
@@ -155,7 +157,7 @@ func IsClaw402Config(aiModel string) bool {
|
||||
// EstimateRunway estimates how many days the given USDC balance will last
|
||||
func EstimateRunway(usdcBalance float64, modelName string, scanIntervalMinutes int) (dailyCost float64, runwayDays float64) {
|
||||
if scanIntervalMinutes <= 0 {
|
||||
scanIntervalMinutes = 3
|
||||
scanIntervalMinutes = 15
|
||||
}
|
||||
callsPerDay := float64(24*60) / float64(scanIntervalMinutes)
|
||||
pricePerCall := GetModelPrice(modelName)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user