mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-29 00:51:22 +08:00
Compare commits
137 Commits
release/me
...
stable
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eba28bcf0e | ||
|
|
c4e79d9579 | ||
|
|
a4983d2cb0 | ||
|
|
24f6421a73 | ||
|
|
961e016d33 | ||
|
|
b95da3ed42 | ||
|
|
a47811f5ab | ||
|
|
2c6e2827e8 | ||
|
|
953240565f | ||
|
|
133ef51de8 | ||
|
|
bebe51bf89 | ||
|
|
3a048876bd | ||
|
|
4f3869c81c | ||
|
|
f3c33b55d7 | ||
|
|
785922697b | ||
|
|
332ddf61ef | ||
|
|
41c2625bb2 | ||
|
|
c0d8a9a375 | ||
|
|
9ea9bd705f | ||
|
|
094ab45476 | ||
|
|
220cb7428b | ||
|
|
1aea7abc38 | ||
|
|
577a0918c3 | ||
|
|
2d32a8f6c9 | ||
|
|
3c061aee94 | ||
|
|
30c6abca74 | ||
|
|
129952859e | ||
|
|
7f0a9f0749 | ||
|
|
b15c2da3a9 | ||
|
|
d008ccc6ab | ||
|
|
e4adafa364 | ||
|
|
1851508353 | ||
|
|
fcb73cc195 | ||
|
|
b9ae99da7e | ||
|
|
75832f9eb2 | ||
|
|
99361cb085 | ||
|
|
70db3f5ba3 | ||
|
|
f2eeea9659 | ||
|
|
eb73c8bdfa | ||
|
|
dea00b418c | ||
|
|
3b2e7027db | ||
|
|
f4ee723aa2 | ||
|
|
5bdffee3b0 | ||
|
|
c7c003cc3c | ||
|
|
f37fc9f887 | ||
|
|
908fc09aca | ||
|
|
ab5873e2de | ||
|
|
d80bb31c0a | ||
|
|
e2ccc6b911 | ||
|
|
bf289e8eb3 | ||
|
|
9f25bf49bf | ||
|
|
b8cde34e67 | ||
|
|
32e8a03a85 | ||
|
|
ca8bed4a58 | ||
|
|
94844b7139 | ||
|
|
e67a927a4f | ||
|
|
0f11be77f8 | ||
|
|
159f27dfdd | ||
|
|
25d0b30ea9 | ||
|
|
2d45e7ab15 | ||
|
|
fc6c42ac11 | ||
|
|
5ff7212cb3 | ||
|
|
3619f82796 | ||
|
|
03a307939e | ||
|
|
8d8a0cc72b | ||
|
|
fe0dbce367 | ||
|
|
b536265f93 | ||
|
|
30a703a827 | ||
|
|
d481b3d88c | ||
|
|
e8eafce1e0 | ||
|
|
ce3a8582af | ||
|
|
cfd91069d3 | ||
|
|
903eb591eb | ||
|
|
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
240
README.md
240
README.md
@@ -1,8 +1,10 @@
|
||||
<p align="center"><strong>Backed by <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
||||
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Your personal AI trading assistant.</strong><br/>
|
||||
<strong>Any market. Any model. Pay with USDC, not API keys.</strong>
|
||||
<strong>AI trading terminal for global markets.</strong><br/>
|
||||
<strong>Research, strategy generation, execution, and monitoring for US stocks, commodities, forex, and crypto.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,8 +17,6 @@
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,90 +31,79 @@
|
||||
|
||||
---
|
||||
|
||||
NOFX is an open-source **autonomous** AI trading assistant. Unlike traditional AI tools that require you to manually configure models, manage API keys, and wire up data sources — NOFX's AI **perceives markets, selects models, and fetches data entirely on its own**. Zero human intervention. You set the strategy, the AI handles everything else.
|
||||
NOFX is an open-source AI trading terminal for active traders who want one workspace for market research, strategy development, execution, and portfolio monitoring.
|
||||
|
||||
**Fully autonomous**: The AI decides which model to use, what market data to pull, when to trade — all by itself. No manual model configuration. No juggling API keys for different services. Just fund a USDC wallet and let it run.
|
||||
|
||||
What makes it different: **built-in [x402](https://x402.org) micropayments**. No API keys. Fund a USDC wallet and pay per request. Your wallet is your identity.
|
||||
The product is built around global liquid markets: US equities, commodity contracts, FX pairs, and digital assets. The AI layer helps translate market intent into watchlists, signals, strategy logic, risk controls, and execution workflows.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Open **http://127.0.0.1:3000**. Done.
|
||||
Open **http://127.0.0.1:3000**.
|
||||
|
||||
---
|
||||
|
||||
## How x402 Works
|
||||
## Register exchanges
|
||||
|
||||
Traditional flow: register account → buy credits → get API key → manage quota → rotate keys.
|
||||
Use the links below to open trading accounts for crypto and supported US stock, FX, and commodity derivative markets. These routes are part of NOFX partner programs and may include fee discounts or referral benefits.
|
||||
|
||||
x402 flow:
|
||||
|
||||
```
|
||||
Request → 402 (here's the price) → wallet signs USDC → retry → done
|
||||
```
|
||||
|
||||
No accounts. No API keys. No prepaid credits. One wallet, every model.
|
||||
|
||||
### Built-in x402 Providers
|
||||
|
||||
| Provider | Chain | Models |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ models |
|
||||
| Exchange | Status | Register with fee discount |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Register](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
|
||||
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Register](https://www.okx.com/join/1865360) |
|
||||
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Register](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Register](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
## Quick demo
|
||||
|
||||
| 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 |
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
### Markets
|
||||
<p align="center">
|
||||
Click the cover image to watch the demo video.
|
||||
</p>
|
||||
|
||||
Crypto · US Stocks · Forex · Metals
|
||||
---
|
||||
|
||||
### Exchanges (CEX)
|
||||
## Markets
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Register](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Register](https://partner.bybit.com/b/83856) |
|
||||
| <img src="web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Register](https://www.okx.com/join/1865360) |
|
||||
| <img src="web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Register](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Register](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Register](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
**US Stocks · Commodities · Forex · Crypto**
|
||||
|
||||
### Exchanges (Perp-DEX)
|
||||
NOFX organizes research, strategy construction, execution, and monitoring around multi-asset workflows instead of single-venue screens.
|
||||
|
||||
| Exchange | Status | Register (Fee Discount) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Register](https://app.lighter.xyz/?referral=68151432) |
|
||||
---
|
||||
|
||||
### AI Models (API Key Mode)
|
||||
## AI model access
|
||||
|
||||
| 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) |
|
||||
NOFX routes AI inference through [Claw402](https://claw402.ai) automatically. Users do not need to configure model providers, manage API keys, or maintain separate AI accounts. The terminal accesses supported models on demand through Claw402's pay-as-you-go infrastructure, with traffic routed through the official discounted channel.
|
||||
|
||||
### AI Models (x402 Mode — No API Key)
|
||||
| Provider | Access |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [Access pay-as-you-go AI models with official discount](https://claw402.ai) |
|
||||
|
||||
15+ models via [Claw402](https://claw402.ai) — just a USDC wallet
|
||||
---
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Capability | Description |
|
||||
| :-------------------------- | :-------------------------------------------------------------------------- |
|
||||
| **AI trading terminal** | Unified workspace for US stocks, commodities, forex, and crypto workflows |
|
||||
| **AI model access** | Unified model access through Claw402-supported providers |
|
||||
| **Exchange connectivity** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, and Lighter |
|
||||
| **Strategy Studio** | Market universes, indicators, risk controls, and strategy logic |
|
||||
| **Model competition** | Compare model-driven traders with live performance and leaderboard tracking |
|
||||
| **Telegram agent** | Control and monitor the trading assistant through chat |
|
||||
| **Portfolio dashboard** | Positions, P/L, execution history, and model decision logs |
|
||||
|
||||
---
|
||||
|
||||
@@ -123,41 +112,45 @@ Crypto · US Stocks · Forex · Metals
|
||||
<details>
|
||||
<summary><b>Config Page</b></summary>
|
||||
|
||||
| AI Models & Exchanges | Traders List |
|
||||
|:---:|:---:|
|
||||
| Configuration | Traders List |
|
||||
| :----------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="screenshots/config-ai-exchanges.png" width="400"/> | <img src="screenshots/config-traders-list.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Dashboard</b></summary>
|
||||
|
||||
| Overview | Market Chart |
|
||||
|:---:|:---:|
|
||||
| Overview | Market Chart |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="screenshots/dashboard-page.png" width="400"/> | <img src="screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| Trading Stats | Position History |
|
||||
|:---:|:---:|
|
||||
| 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 |
|
||||
|:---:|:---:|
|
||||
| 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 |
|
||||
|:---:|:---:|
|
||||
| Strategy Editor | Indicators Config |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="screenshots/strategy-studio.png" width="400"/> | <img src="screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Competition</b></summary>
|
||||
|
||||
| Competition Mode |
|
||||
|:---:|
|
||||
| Competition Mode |
|
||||
| :-------------------------------------------------------: |
|
||||
| <img src="screenshots/competition-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
@@ -212,29 +205,31 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
|
||||
## Setup
|
||||
|
||||
**Beginner mode**: First-time users get a guided onboarding flow — select beginner mode at registration and the system walks you through AI, exchange, and strategy setup step by step.
|
||||
**Beginner mode**: Guided onboarding walks new users through model selection, exchange connection, strategy setup, and first deployment.
|
||||
|
||||
**Advanced mode**:
|
||||
|
||||
1. **AI** — Add API keys or configure x402 wallet
|
||||
2. **Exchange** — Connect exchange API credentials
|
||||
3. **Strategy** — Build in Strategy Studio
|
||||
4. **Trader** — Combine AI + Exchange + Strategy
|
||||
5. **Trade** — Launch from the dashboard
|
||||
1. Configure AI model access
|
||||
2. Connect exchange credentials
|
||||
3. Build or import a strategy
|
||||
4. Create an AI trader profile
|
||||
5. Launch, monitor, and iterate from the dashboard
|
||||
|
||||
Everything through the web UI at **http://127.0.0.1:3000**.
|
||||
All configuration is available from the web UI at **http://127.0.0.1:3000**.
|
||||
|
||||
---
|
||||
|
||||
## Deploy to Server
|
||||
## Deploy to server
|
||||
|
||||
**HTTP deployment:**
|
||||
|
||||
**HTTP (quick):**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# Access via http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**HTTPS (Cloudflare):**
|
||||
**HTTPS via Cloudflare:**
|
||||
|
||||
1. Add domain to [Cloudflare](https://dash.cloudflare.com) (free plan)
|
||||
2. A record → your server IP (Proxied)
|
||||
3. SSL/TLS → Flexible
|
||||
@@ -247,24 +242,22 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Web Dashboard │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
├──────────┬──────────┬──────────┬────────────────┤
|
||||
│ Strategy │ Telegram │
|
||||
│ Engine │ Agent │
|
||||
├──────────┴──────────┴──────────┴────────────────┤
|
||||
│ MCP AI Client Layer │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ API Key │ │ x402 │ │ │ │
|
||||
│ │ DeepSeek │ │ Claw402 │ │ │ │
|
||||
│ │ GPT,Claude │ │ │ │ │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectors │
|
||||
│ Binance · Bybit · OKX · Bitget · KuCoin · Gate │
|
||||
│ Hyperliquid · Aster DEX · Lighter │
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -272,47 +265,44 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
|
||||
## Docs
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| [Architecture](docs/architecture/README.md) | System design and module index |
|
||||
| | |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [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/faq/README.md) | Common questions |
|
||||
| [Getting Started](docs/getting-started/README.md) | Deployment guide |
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
See [Contributing Guide](CONTRIBUTING.md) · [Code of Conduct](CODE_OF_CONDUCT.md) · [Security Policy](SECURITY.md)
|
||||
See [Contributing Guide](CONTRIBUTING.md), [Code of Conduct](CODE_OF_CONDUCT.md), and [Security Policy](SECURITY.md).
|
||||
|
||||
### Contributor Airdrop Program
|
||||
|
||||
All contributions are tracked. When NOFX generates revenue, contributors receive airdrops.
|
||||
NOFX tracks meaningful contributions and intends to reward contributors as the ecosystem grows. Priority issues carry higher reward weight.
|
||||
|
||||
**[Pinned Issues](https://github.com/NoFxAiOS/nofx/issues) get the highest rewards.**
|
||||
|
||||
| Contribution | Weight |
|
||||
|:-------------|:------:|
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
| Contribution | Weight |
|
||||
| :---------------- | :----: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| 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) |
|
||||
| | |
|
||||
| :-------- | :---------------------------------------------------- |
|
||||
| Website | [vergex.trade](https://vergex.trade) |
|
||||
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
|
||||
|
||||
> **Risk Warning**: AI auto-trading carries significant risks. Recommended for learning/research or small amounts only.
|
||||
> **Risk warning**: Automated trading involves substantial risk. Use appropriate position sizing, understand each exchange venue, and do not trade funds you cannot afford to lose.
|
||||
|
||||
---
|
||||
|
||||
|
||||
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,29 +319,23 @@ func accountAssetForExchange(exchangeType string) string {
|
||||
}
|
||||
|
||||
func missingExchangeCredentials(exchangeCfg *store.Exchange) (status string, code string, message string, missing bool) {
|
||||
switch exchangeCfg.ExchangeType {
|
||||
case "binance", "bybit", "gate", "indodax":
|
||||
if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key and secret key are required", true
|
||||
missingFields := store.MissingRequiredExchangeCredentialFields(
|
||||
exchangeCfg.ExchangeType,
|
||||
string(exchangeCfg.APIKey),
|
||||
string(exchangeCfg.SecretKey),
|
||||
string(exchangeCfg.Passphrase),
|
||||
exchangeCfg.HyperliquidWalletAddr,
|
||||
exchangeCfg.AsterUser,
|
||||
exchangeCfg.AsterSigner,
|
||||
string(exchangeCfg.AsterPrivateKey),
|
||||
exchangeCfg.LighterWalletAddr,
|
||||
string(exchangeCfg.LighterAPIKeyPrivateKey),
|
||||
)
|
||||
if len(missingFields) > 0 {
|
||||
if len(missingFields) == 1 && missingFields[0] == "exchange_type" {
|
||||
return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type", true
|
||||
}
|
||||
case "okx", "bitget", "kucoin":
|
||||
if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" || exchangeCfg.Passphrase == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key, secret key, and passphrase are required", true
|
||||
}
|
||||
case "hyperliquid":
|
||||
if exchangeCfg.APIKey == "" || exchangeCfg.HyperliquidWalletAddr == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Private key and wallet address are required", true
|
||||
}
|
||||
case "aster":
|
||||
if exchangeCfg.AsterUser == "" || exchangeCfg.AsterSigner == "" || exchangeCfg.AsterPrivateKey == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Aster user, signer, and private key are required", true
|
||||
}
|
||||
case "lighter":
|
||||
if exchangeCfg.LighterWalletAddr == "" || exchangeCfg.LighterAPIKeyPrivateKey == "" {
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Wallet address and API key private key are required", true
|
||||
}
|
||||
default:
|
||||
return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type", true
|
||||
return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Missing required fields: " + strings.Join(missingFields, ", "), true
|
||||
}
|
||||
|
||||
return "", "", "", false
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"nofx/crypto"
|
||||
"nofx/logger"
|
||||
"nofx/security"
|
||||
"nofx/store"
|
||||
"nofx/wallet"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
// ModelConfigUpdate is a single model's update payload. It is a named type
|
||||
// (rather than an inline anonymous struct) so the log-sanitizer in utils.go is
|
||||
// guaranteed to stay in sync with this shape — a mismatch there is what let
|
||||
// plaintext credentials reach the logs previously.
|
||||
type ModelConfigUpdate struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
CustomAPIURL string `json:"custom_api_url"`
|
||||
CustomModelName string `json:"custom_model_name"`
|
||||
}
|
||||
|
||||
type UpdateModelConfigRequest struct {
|
||||
Models map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
CustomAPIURL string `json:"custom_api_url"`
|
||||
CustomModelName string `json:"custom_model_name"`
|
||||
} `json:"models"`
|
||||
Models map[string]ModelConfigUpdate `json:"models"`
|
||||
}
|
||||
|
||||
// handleGetModelConfigs Get AI model configurations
|
||||
@@ -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,18 +432,20 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[s
|
||||
history := make([]map[string]interface{}, 0, len(snapshots)+1)
|
||||
var lastSnapshotTime time.Time
|
||||
for _, snap := range snapshots {
|
||||
totalPnL := snap.TotalEquity - initialBalance
|
||||
// Calculate PnL percentage: (current_equity - initial_balance) / initial_balance * 100
|
||||
pnlPct := 0.0
|
||||
if initialBalance > 0 {
|
||||
pnlPct = (snap.TotalEquity - initialBalance) / initialBalance * 100
|
||||
pnlPct = totalPnL / initialBalance * 100
|
||||
}
|
||||
|
||||
history = append(history, map[string]interface{}{
|
||||
"timestamp": snap.Timestamp,
|
||||
"total_equity": snap.TotalEquity,
|
||||
"total_pnl": snap.UnrealizedPnL,
|
||||
"total_pnl_pct": pnlPct,
|
||||
"balance": snap.Balance,
|
||||
"timestamp": snap.Timestamp,
|
||||
"total_equity": snap.TotalEquity,
|
||||
"available_balance": equitySnapshotAvailableBalance(snap),
|
||||
"total_pnl": totalPnL,
|
||||
"total_pnl_pct": pnlPct,
|
||||
"balance": snap.Balance,
|
||||
})
|
||||
if snap.Timestamp.After(lastSnapshotTime) {
|
||||
lastSnapshotTime = snap.Timestamp
|
||||
@@ -397,29 +458,21 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) map[s
|
||||
if accountInfo, err := trader.GetAccountInfo(); err == nil {
|
||||
// Only append if it's been more than 30 seconds since last snapshot
|
||||
if now.Sub(lastSnapshotTime) > 30*time.Second {
|
||||
totalEquity := 0.0
|
||||
if v, ok := accountInfo["total_equity"].(float64); ok {
|
||||
totalEquity = v
|
||||
}
|
||||
totalPnL := 0.0
|
||||
if v, ok := accountInfo["total_pnl"].(float64); ok {
|
||||
totalPnL = v
|
||||
}
|
||||
walletBalance := 0.0
|
||||
if v, ok := accountInfo["wallet_balance"].(float64); ok {
|
||||
walletBalance = v
|
||||
}
|
||||
totalEquity := floatFromMap(accountInfo, "total_equity")
|
||||
totalPnL := totalEquity - initialBalance
|
||||
walletBalance := floatFromMap(accountInfo, "wallet_balance")
|
||||
pnlPct := 0.0
|
||||
if initialBalance > 0 {
|
||||
pnlPct = (totalEquity - initialBalance) / initialBalance * 100
|
||||
}
|
||||
|
||||
history = append(history, map[string]interface{}{
|
||||
"timestamp": now,
|
||||
"total_equity": totalEquity,
|
||||
"total_pnl": totalPnL,
|
||||
"total_pnl_pct": pnlPct,
|
||||
"balance": walletBalance,
|
||||
"timestamp": now,
|
||||
"total_equity": totalEquity,
|
||||
"available_balance": floatFromMap(accountInfo, "available_balance"),
|
||||
"total_pnl": totalPnL,
|
||||
"total_pnl_pct": pnlPct,
|
||||
"balance": walletBalance,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"nofx/config"
|
||||
"nofx/crypto"
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -24,56 +26,96 @@ type ExchangeConfig struct {
|
||||
|
||||
// SafeExchangeConfig Safe exchange configuration structure (does not contain sensitive information)
|
||||
type SafeExchangeConfig struct {
|
||||
ID string `json:"id"` // UUID
|
||||
ExchangeType string `json:"exchange_type"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
AccountName string `json:"account_name"` // User-defined account name
|
||||
Name string `json:"name"` // Display name
|
||||
Type string `json:"type"` // "cex" or "dex"
|
||||
Enabled bool `json:"enabled"`
|
||||
Testnet bool `json:"testnet,omitempty"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
|
||||
AsterUser string `json:"asterUser"` // Aster username (not sensitive)
|
||||
AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
|
||||
LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive)
|
||||
ID string `json:"id"` // UUID
|
||||
ExchangeType string `json:"exchange_type"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
AccountName string `json:"account_name"` // User-defined account name
|
||||
Name string `json:"name"` // Display name
|
||||
Type string `json:"type"` // "cex" or "dex"
|
||||
Enabled bool `json:"enabled"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
HasSecretKey bool `json:"has_secret_key"`
|
||||
HasPassphrase bool `json:"has_passphrase"`
|
||||
Testnet bool `json:"testnet,omitempty"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquidUnifiedAccount"`
|
||||
HyperliquidBuilderApproved bool `json:"hyperliquidBuilderApproved"`
|
||||
HasAsterPrivateKey bool `json:"has_aster_private_key"`
|
||||
AsterUser string `json:"asterUser"` // Aster username (not sensitive)
|
||||
AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
|
||||
LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive)
|
||||
HasLighterPrivateKey bool `json:"has_lighter_private_key"`
|
||||
HasLighterAPIKey bool `json:"has_lighter_api_key_private_key"`
|
||||
}
|
||||
|
||||
func safeExchangeConfigFromStore(exchange *store.Exchange) SafeExchangeConfig {
|
||||
return SafeExchangeConfig{
|
||||
ID: exchange.ID,
|
||||
ExchangeType: exchange.ExchangeType,
|
||||
AccountName: exchange.AccountName,
|
||||
Name: exchange.Name,
|
||||
Type: exchange.Type,
|
||||
Enabled: exchange.Enabled,
|
||||
HasAPIKey: exchange.APIKey != "",
|
||||
HasSecretKey: exchange.SecretKey != "",
|
||||
HasPassphrase: exchange.Passphrase != "",
|
||||
Testnet: exchange.Testnet,
|
||||
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
|
||||
HyperliquidUnifiedAcct: exchange.HyperliquidUnifiedAcct,
|
||||
HyperliquidBuilderApproved: exchange.HyperliquidBuilderApproved,
|
||||
HasAsterPrivateKey: exchange.AsterPrivateKey != "",
|
||||
AsterUser: exchange.AsterUser,
|
||||
AsterSigner: exchange.AsterSigner,
|
||||
LighterWalletAddr: exchange.LighterWalletAddr,
|
||||
HasLighterPrivateKey: exchange.LighterPrivateKey != "",
|
||||
HasLighterAPIKey: exchange.LighterAPIKeyPrivateKey != "",
|
||||
}
|
||||
}
|
||||
|
||||
// ExchangeConfigUpdate is a single exchange account's update payload. It is a
|
||||
// named type (rather than an inline anonymous struct) so the log-sanitizer in
|
||||
// utils.go is guaranteed to cover every sensitive field — a drift between the
|
||||
// two shapes is what let passphrases / private keys reach the logs previously.
|
||||
type ExchangeConfigUpdate struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Passphrase string `json:"passphrase"` // OKX specific
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode
|
||||
HyperliquidBuilderApproved *bool `json:"hyperliquid_builder_approved"`
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||
}
|
||||
|
||||
type UpdateExchangeConfigRequest struct {
|
||||
Exchanges map[string]struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Passphrase string `json:"passphrase"` // OKX specific
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||
} `json:"exchanges"`
|
||||
Exchanges map[string]ExchangeConfigUpdate `json:"exchanges"`
|
||||
}
|
||||
|
||||
// CreateExchangeRequest request structure for creating a new exchange account
|
||||
type CreateExchangeRequest struct {
|
||||
ExchangeType string `json:"exchange_type" binding:"required"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
AccountName string `json:"account_name"` // User-defined account name
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Passphrase string `json:"passphrase"`
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||
ExchangeType string `json:"exchange_type" binding:"required"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
AccountName string `json:"account_name"` // User-defined account name
|
||||
Enabled bool `json:"enabled"`
|
||||
APIKey string `json:"api_key"`
|
||||
SecretKey string `json:"secret_key"`
|
||||
Passphrase string `json:"passphrase"`
|
||||
Testnet bool `json:"testnet"`
|
||||
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
||||
HyperliquidUnifiedAcct *bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
|
||||
HyperliquidBuilderApproved bool `json:"hyperliquid_builder_approved"`
|
||||
AsterUser string `json:"aster_user"`
|
||||
AsterSigner string `json:"aster_signer"`
|
||||
AsterPrivateKey string `json:"aster_private_key"`
|
||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||
}
|
||||
|
||||
// handleGetExchangeConfigs Get exchange configurations
|
||||
@@ -96,26 +138,30 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
|
||||
logger.Infof("✅ Found %d exchange configs", len(exchanges))
|
||||
|
||||
// Convert to safe response structure, remove sensitive information
|
||||
safeExchanges := make([]SafeExchangeConfig, len(exchanges))
|
||||
for i, exchange := range exchanges {
|
||||
safeExchanges[i] = SafeExchangeConfig{
|
||||
ID: exchange.ID,
|
||||
ExchangeType: exchange.ExchangeType,
|
||||
AccountName: exchange.AccountName,
|
||||
Name: exchange.Name,
|
||||
Type: exchange.Type,
|
||||
Enabled: exchange.Enabled,
|
||||
Testnet: exchange.Testnet,
|
||||
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
|
||||
AsterUser: exchange.AsterUser,
|
||||
AsterSigner: exchange.AsterSigner,
|
||||
LighterWalletAddr: exchange.LighterWalletAddr,
|
||||
safeExchanges := make([]SafeExchangeConfig, 0, len(exchanges))
|
||||
for _, exchange := range exchanges {
|
||||
if !store.IsVisibleExchange(exchange) {
|
||||
continue
|
||||
}
|
||||
safeExchanges = append(safeExchanges, safeExchangeConfigFromStore(exchange))
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, safeExchanges)
|
||||
}
|
||||
|
||||
func effectiveHyperliquidUnifiedAccount(exchangeType string, requested *bool, fallback ...bool) bool {
|
||||
if requested != nil {
|
||||
return *requested
|
||||
}
|
||||
if strings.EqualFold(exchangeType, "hyperliquid") {
|
||||
if len(fallback) > 0 {
|
||||
return fallback[0]
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handleUpdateExchangeConfigs Update exchange configurations (supports both encrypted and plain text based on config)
|
||||
func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
@@ -179,13 +225,83 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
// Update each exchange's configuration and track traders that need reload
|
||||
tradersToReload := make(map[string]bool)
|
||||
for exchangeID, exchangeData := range req.Exchanges {
|
||||
existing, err := s.store.Exchange().GetByID(userID, exchangeID)
|
||||
if err != nil {
|
||||
SafeInternalError(c, fmt.Sprintf("Load exchange %s", exchangeID), err)
|
||||
return
|
||||
}
|
||||
effectiveAPIKey := strings.TrimSpace(exchangeData.APIKey)
|
||||
if effectiveAPIKey == "" {
|
||||
effectiveAPIKey = strings.TrimSpace(string(existing.APIKey))
|
||||
}
|
||||
effectiveSecretKey := strings.TrimSpace(exchangeData.SecretKey)
|
||||
if effectiveSecretKey == "" {
|
||||
effectiveSecretKey = strings.TrimSpace(string(existing.SecretKey))
|
||||
}
|
||||
effectivePassphrase := strings.TrimSpace(exchangeData.Passphrase)
|
||||
if effectivePassphrase == "" {
|
||||
effectivePassphrase = strings.TrimSpace(string(existing.Passphrase))
|
||||
}
|
||||
effectiveAsterPrivateKey := strings.TrimSpace(exchangeData.AsterPrivateKey)
|
||||
if effectiveAsterPrivateKey == "" {
|
||||
effectiveAsterPrivateKey = strings.TrimSpace(string(existing.AsterPrivateKey))
|
||||
}
|
||||
effectiveLighterAPIKeyPrivateKey := strings.TrimSpace(exchangeData.LighterAPIKeyPrivateKey)
|
||||
if effectiveLighterAPIKeyPrivateKey == "" {
|
||||
effectiveLighterAPIKeyPrivateKey = strings.TrimSpace(string(existing.LighterAPIKeyPrivateKey))
|
||||
}
|
||||
effectiveHyperliquidWalletAddr := strings.TrimSpace(exchangeData.HyperliquidWalletAddr)
|
||||
if effectiveHyperliquidWalletAddr == "" {
|
||||
effectiveHyperliquidWalletAddr = strings.TrimSpace(existing.HyperliquidWalletAddr)
|
||||
}
|
||||
effectiveAsterUser := strings.TrimSpace(exchangeData.AsterUser)
|
||||
if effectiveAsterUser == "" {
|
||||
effectiveAsterUser = strings.TrimSpace(existing.AsterUser)
|
||||
}
|
||||
effectiveAsterSigner := strings.TrimSpace(exchangeData.AsterSigner)
|
||||
if effectiveAsterSigner == "" {
|
||||
effectiveAsterSigner = strings.TrimSpace(existing.AsterSigner)
|
||||
}
|
||||
effectiveLighterWalletAddr := strings.TrimSpace(exchangeData.LighterWalletAddr)
|
||||
if effectiveLighterWalletAddr == "" {
|
||||
effectiveLighterWalletAddr = strings.TrimSpace(existing.LighterWalletAddr)
|
||||
}
|
||||
effectiveHyperliquidBuilderApproved := existing.HyperliquidBuilderApproved
|
||||
if exchangeData.HyperliquidBuilderApproved != nil {
|
||||
effectiveHyperliquidBuilderApproved = *exchangeData.HyperliquidBuilderApproved
|
||||
}
|
||||
effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount(
|
||||
existing.ExchangeType,
|
||||
exchangeData.HyperliquidUnifiedAcct,
|
||||
existing.HyperliquidUnifiedAcct,
|
||||
)
|
||||
|
||||
if missing := store.MissingRequiredExchangeCredentialFields(
|
||||
existing.ExchangeType,
|
||||
effectiveAPIKey,
|
||||
effectiveSecretKey,
|
||||
effectivePassphrase,
|
||||
effectiveHyperliquidWalletAddr,
|
||||
effectiveAsterUser,
|
||||
effectiveAsterSigner,
|
||||
effectiveAsterPrivateKey,
|
||||
effectiveLighterWalletAddr,
|
||||
effectiveLighterAPIKeyPrivateKey,
|
||||
); len(missing) > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Missing required exchange fields: %s", strings.Join(missing, ", ")),
|
||||
"missing_fields": missing,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Find traders using this exchange BEFORE updating
|
||||
traders, _ := s.store.Trader().ListByExchangeID(userID, exchangeID)
|
||||
for _, t := range traders {
|
||||
tradersToReload[t.ID] = true
|
||||
}
|
||||
|
||||
err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
|
||||
err = s.store.Exchange().Update(userID, exchangeID, true, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, effectiveHyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, effectiveHyperliquidBuilderApproved, effectiveAsterUser, effectiveAsterSigner, exchangeData.AsterPrivateKey, effectiveLighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
|
||||
if err != nil {
|
||||
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
|
||||
return
|
||||
@@ -207,7 +323,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
// Don't return error here since exchange config was successfully updated to database
|
||||
}
|
||||
|
||||
logger.Infof("✓ Exchange config updated: %+v", req.Exchanges)
|
||||
logger.Infof("✓ Exchange config updated: %+v", SanitizeExchangeConfigForLog(req.Exchanges))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Exchange configuration updated"})
|
||||
}
|
||||
|
||||
@@ -271,12 +387,31 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)})
|
||||
return
|
||||
}
|
||||
if missing := store.MissingRequiredExchangeCredentialFields(
|
||||
req.ExchangeType,
|
||||
req.APIKey,
|
||||
req.SecretKey,
|
||||
req.Passphrase,
|
||||
req.HyperliquidWalletAddr,
|
||||
req.AsterUser,
|
||||
req.AsterSigner,
|
||||
req.AsterPrivateKey,
|
||||
req.LighterWalletAddr,
|
||||
req.LighterAPIKeyPrivateKey,
|
||||
); len(missing) > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Missing required exchange fields: %s", strings.Join(missing, ", ")),
|
||||
"missing_fields": missing,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create new exchange account
|
||||
// Exchange configs only persist once complete; persisted configs are always enabled.
|
||||
effectiveHyperliquidUnifiedAcct := effectiveHyperliquidUnifiedAccount(req.ExchangeType, req.HyperliquidUnifiedAcct)
|
||||
id, err := s.store.Exchange().Create(
|
||||
userID, req.ExchangeType, req.AccountName, req.Enabled,
|
||||
userID, req.ExchangeType, req.AccountName, true,
|
||||
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
|
||||
req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct,
|
||||
req.HyperliquidWalletAddr, effectiveHyperliquidUnifiedAcct, req.HyperliquidBuilderApproved,
|
||||
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
|
||||
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
|
||||
)
|
||||
|
||||
66
api/handler_exchange_test.go
Normal file
66
api/handler_exchange_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"nofx/crypto"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
func TestSafeExchangeConfigFromStoreIncludesCredentialPresenceFlags(t *testing.T) {
|
||||
cfg := &store.Exchange{
|
||||
ID: "ex-1",
|
||||
ExchangeType: "okx",
|
||||
AccountName: "OKX Main",
|
||||
Name: "OKX Main",
|
||||
Type: "cex",
|
||||
Enabled: true,
|
||||
APIKey: crypto.EncryptedString("api-test-123"),
|
||||
SecretKey: crypto.EncryptedString("secret-test-123"),
|
||||
Passphrase: crypto.EncryptedString("passphrase-test-123"),
|
||||
HyperliquidUnifiedAcct: true,
|
||||
AsterPrivateKey: crypto.EncryptedString("aster-private-key"),
|
||||
LighterPrivateKey: crypto.EncryptedString("lighter-private-key"),
|
||||
LighterAPIKeyPrivateKey: crypto.EncryptedString("lighter-api-key-private-key"),
|
||||
}
|
||||
|
||||
safe := safeExchangeConfigFromStore(cfg)
|
||||
if !safe.HasAPIKey {
|
||||
t.Fatalf("expected has_api_key to be true")
|
||||
}
|
||||
if !safe.HasSecretKey {
|
||||
t.Fatalf("expected has_secret_key to be true")
|
||||
}
|
||||
if !safe.HasPassphrase {
|
||||
t.Fatalf("expected has_passphrase to be true")
|
||||
}
|
||||
if !safe.HasAsterPrivateKey {
|
||||
t.Fatalf("expected has_aster_private_key to be true")
|
||||
}
|
||||
if !safe.HasLighterPrivateKey {
|
||||
t.Fatalf("expected has_lighter_private_key to be true")
|
||||
}
|
||||
if !safe.HasLighterAPIKey {
|
||||
t.Fatalf("expected has_lighter_api_key_private_key to be true")
|
||||
}
|
||||
if !safe.HyperliquidUnifiedAcct {
|
||||
t.Fatalf("expected hyperliquid unified account to be exposed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveHyperliquidUnifiedAccountDefaultsAndPreserves(t *testing.T) {
|
||||
if !effectiveHyperliquidUnifiedAccount("hyperliquid", nil) {
|
||||
t.Fatalf("expected new hyperliquid accounts to default unified account on")
|
||||
}
|
||||
if effectiveHyperliquidUnifiedAccount("binance", nil) {
|
||||
t.Fatalf("expected non-hyperliquid accounts to default unified account off")
|
||||
}
|
||||
fallbackFalse := effectiveHyperliquidUnifiedAccount("hyperliquid", nil, false)
|
||||
if fallbackFalse {
|
||||
t.Fatalf("expected omitted update field to preserve existing false value")
|
||||
}
|
||||
requestedTrue := true
|
||||
if !effectiveHyperliquidUnifiedAccount("hyperliquid", &requestedTrue, false) {
|
||||
t.Fatalf("expected explicit true to override existing false value")
|
||||
}
|
||||
}
|
||||
413
api/handler_hyperliquid_wallet.go
Normal file
413
api/handler_hyperliquid_wallet.go
Normal file
@@ -0,0 +1,413 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultHyperliquidBuilderAddress = "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d"
|
||||
// 0.05% (万5) — matches BuilderInfo.Fee=50 charged at order placement.
|
||||
// New wallet approvals sign this exact value; existing approvals at the
|
||||
// prior 0.1% cap remain valid because 0.05% is within their approved max.
|
||||
defaultHyperliquidBuilderMaxFee = "0.05%"
|
||||
hyperliquidExchangeURL = "https://api.hyperliquid.xyz/exchange"
|
||||
hyperliquidInfoURL = "https://api.hyperliquid.xyz/info"
|
||||
// nofxHyperliquidAgentName must match AGENT_NAME used by the frontend
|
||||
// approveAgent flow so we can locate the NOFX-managed agent on-chain.
|
||||
nofxHyperliquidAgentName = "NOFX Agent"
|
||||
)
|
||||
|
||||
type hyperliquidSubmitRequest struct {
|
||||
Action map[string]any `json:"action" binding:"required"`
|
||||
Nonce int64 `json:"nonce" binding:"required"`
|
||||
Signature struct {
|
||||
R string `json:"r" binding:"required"`
|
||||
S string `json:"s" binding:"required"`
|
||||
V int `json:"v"`
|
||||
} `json:"signature" binding:"required"`
|
||||
}
|
||||
|
||||
type hyperliquidConfigResponse struct {
|
||||
BuilderAddress string `json:"builderAddress"`
|
||||
BuilderMaxFee string `json:"builderMaxFee"`
|
||||
Chain string `json:"chain"`
|
||||
SignatureChain string `json:"signatureChainId"`
|
||||
}
|
||||
|
||||
type hyperliquidAccountSummary struct {
|
||||
Address string `json:"address"`
|
||||
AccountValue float64 `json:"accountValue"`
|
||||
Withdrawable float64 `json:"withdrawable"`
|
||||
TotalMarginUsed float64 `json:"totalMarginUsed"`
|
||||
UnrealizedPnl float64 `json:"unrealizedPnl"`
|
||||
OpenPositions int `json:"openPositions"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type hyperliquidAgentInfo struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
ValidUntil int64 `json:"validUntil"` // unix milliseconds
|
||||
}
|
||||
|
||||
type hyperliquidAgentResponse struct {
|
||||
// Agent is the NOFX-managed agent ("NOFX Agent"), nil when none is approved.
|
||||
Agent *hyperliquidAgentInfo `json:"agent"`
|
||||
// Agents lists every approved agent for the wallet (for visibility/cleanup).
|
||||
Agents []hyperliquidAgentInfo `json:"agents"`
|
||||
}
|
||||
|
||||
type hyperliquidClearinghouseState struct {
|
||||
MarginSummary struct {
|
||||
AccountValue string `json:"accountValue"`
|
||||
TotalMarginUsed string `json:"totalMarginUsed"`
|
||||
} `json:"marginSummary"`
|
||||
CrossMarginSummary struct {
|
||||
AccountValue string `json:"accountValue"`
|
||||
TotalMarginUsed string `json:"totalMarginUsed"`
|
||||
} `json:"crossMarginSummary"`
|
||||
Withdrawable string `json:"withdrawable"`
|
||||
AssetPositions []struct {
|
||||
Position struct {
|
||||
Szi string `json:"szi"`
|
||||
UnrealizedPnl string `json:"unrealizedPnl"`
|
||||
} `json:"position"`
|
||||
} `json:"assetPositions"`
|
||||
}
|
||||
|
||||
// agentValidUntilSuffix matches the " valid_until <ms>" suffix Hyperliquid uses
|
||||
// to encode an agent's expiry inside the agent name. Hyperliquid normally strips
|
||||
// it from the stored name, but we strip defensively before matching the slot.
|
||||
var agentValidUntilSuffix = regexp.MustCompile(` valid_until \d+$`)
|
||||
|
||||
func baseAgentName(name string) string {
|
||||
return strings.TrimSpace(agentValidUntilSuffix.ReplaceAllString(name, ""))
|
||||
}
|
||||
|
||||
func hyperliquidBuilderAddress() string {
|
||||
return defaultHyperliquidBuilderAddress
|
||||
}
|
||||
|
||||
func hyperliquidBuilderMaxFee() string {
|
||||
return defaultHyperliquidBuilderMaxFee
|
||||
}
|
||||
|
||||
func (s *Server) handleHyperliquidConnectConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, hyperliquidConfigResponse{
|
||||
BuilderAddress: hyperliquidBuilderAddress(),
|
||||
BuilderMaxFee: hyperliquidBuilderMaxFee(),
|
||||
Chain: "Mainnet",
|
||||
SignatureChain: "0x66eee",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHyperliquidAccount(c *gin.Context) {
|
||||
address := strings.ToLower(strings.TrimSpace(c.Query("address")))
|
||||
if !isEVMAddress(address) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"})
|
||||
return
|
||||
}
|
||||
|
||||
requestBody := map[string]any{
|
||||
"type": "clearinghouseState",
|
||||
"user": address,
|
||||
}
|
||||
body, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid balance request"})
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid balance request"})
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the balance request", "status": resp.StatusCode})
|
||||
return
|
||||
}
|
||||
|
||||
var state hyperliquidClearinghouseState
|
||||
if err := json.Unmarshal(respBody, &state); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid balance response"})
|
||||
return
|
||||
}
|
||||
|
||||
accountValue := parseFloatOrZero(state.MarginSummary.AccountValue)
|
||||
if accountValue == 0 {
|
||||
accountValue = parseFloatOrZero(state.CrossMarginSummary.AccountValue)
|
||||
}
|
||||
marginUsed := parseFloatOrZero(state.MarginSummary.TotalMarginUsed)
|
||||
if marginUsed == 0 {
|
||||
marginUsed = parseFloatOrZero(state.CrossMarginSummary.TotalMarginUsed)
|
||||
}
|
||||
|
||||
var unrealizedPnl float64
|
||||
openPositions := 0
|
||||
for _, position := range state.AssetPositions {
|
||||
size := parseFloatOrZero(position.Position.Szi)
|
||||
if size != 0 {
|
||||
openPositions++
|
||||
}
|
||||
unrealizedPnl += parseFloatOrZero(position.Position.UnrealizedPnl)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, hyperliquidAccountSummary{
|
||||
Address: address,
|
||||
AccountValue: accountValue,
|
||||
Withdrawable: parseFloatOrZero(state.Withdrawable),
|
||||
TotalMarginUsed: marginUsed,
|
||||
UnrealizedPnl: unrealizedPnl,
|
||||
OpenPositions: openPositions,
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleHyperliquidAgent reports the on-chain approved agents for a wallet,
|
||||
// including the NOFX agent's validUntil so the UI can show the expiry date and
|
||||
// warn before the 180-day authorization lapses.
|
||||
func (s *Server) handleHyperliquidAgent(c *gin.Context) {
|
||||
address := strings.ToLower(strings.TrimSpace(c.Query("address")))
|
||||
if !isEVMAddress(address) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid wallet address"})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := json.Marshal(map[string]any{"type": "extraAgents", "user": address})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid agent request"})
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidInfoURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid agent request"})
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the agent request", "status": resp.StatusCode})
|
||||
return
|
||||
}
|
||||
|
||||
// extraAgents returns null when no agents are approved.
|
||||
agents := []hyperliquidAgentInfo{}
|
||||
if len(respBody) > 0 && string(bytes.TrimSpace(respBody)) != "null" {
|
||||
if err := json.Unmarshal(respBody, &agents); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse Hyperliquid agent response"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
out := hyperliquidAgentResponse{Agents: agents}
|
||||
for i := range agents {
|
||||
if strings.EqualFold(baseAgentName(agents[i].Name), nofxHyperliquidAgentName) {
|
||||
agent := agents[i]
|
||||
out.Agent = &agent
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleHyperliquidSubmitExchange(c *gin.Context) {
|
||||
var req hyperliquidSubmitRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid Hyperliquid submit payload"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateSubmittedNonce(req.Action, req.Nonce); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
actionType, _ := req.Action["type"].(string)
|
||||
switch actionType {
|
||||
case "approveAgent":
|
||||
if err := validateApproveAgentAction(req.Action); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
case "approveBuilderFee":
|
||||
if err := validateApproveBuilderFeeAction(req.Action); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported Hyperliquid action"})
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"action": req.Action,
|
||||
"nonce": req.Nonce,
|
||||
"signature": req.Signature,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode Hyperliquid payload"})
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
hlReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, hyperliquidExchangeURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create Hyperliquid request"})
|
||||
return
|
||||
}
|
||||
hlReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(hlReq)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Hyperliquid", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var decoded any
|
||||
if len(respBody) > 0 {
|
||||
_ = json.Unmarshal(respBody, &decoded)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Hyperliquid rejected the action", "status": resp.StatusCode, "response": decoded})
|
||||
return
|
||||
}
|
||||
|
||||
// Hyperliquid returns HTTP 200 even for logical failures, signalling them via
|
||||
// {"status":"err","response":"<message>"}. Without this check a rejected
|
||||
// approval (e.g. valid_until past the cap, or an unchanged agent) is reported
|
||||
// to the user as success while nothing changes on-chain.
|
||||
var hlResp struct {
|
||||
Status string `json:"status"`
|
||||
Response json.RawMessage `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &hlResp); err == nil && strings.EqualFold(hlResp.Status, "err") {
|
||||
msg := strings.TrimSpace(strings.Trim(string(hlResp.Response), `"`))
|
||||
if msg == "" {
|
||||
msg = "Hyperliquid rejected the action"
|
||||
}
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": msg, "response": decoded})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "response": decoded})
|
||||
}
|
||||
|
||||
func validateApproveAgentAction(action map[string]any) error {
|
||||
if strings.TrimSpace(fmt.Sprint(action["agentAddress"])) == "" {
|
||||
return fmt.Errorf("missing agentAddress")
|
||||
}
|
||||
if strings.TrimSpace(fmt.Sprint(action["agentName"])) == "" {
|
||||
return fmt.Errorf("missing agentName")
|
||||
}
|
||||
return validateCommonHyperliquidSignedAction(action)
|
||||
}
|
||||
|
||||
func validateApproveBuilderFeeAction(action map[string]any) error {
|
||||
builder := strings.ToLower(strings.TrimSpace(fmt.Sprint(action["builder"])))
|
||||
if builder != hyperliquidBuilderAddress() {
|
||||
return fmt.Errorf("builder address mismatch")
|
||||
}
|
||||
if strings.TrimSpace(fmt.Sprint(action["maxFeeRate"])) != hyperliquidBuilderMaxFee() {
|
||||
return fmt.Errorf("builder max fee mismatch")
|
||||
}
|
||||
return validateCommonHyperliquidSignedAction(action)
|
||||
}
|
||||
|
||||
func validateCommonHyperliquidSignedAction(action map[string]any) error {
|
||||
if strings.TrimSpace(fmt.Sprint(action["signatureChainId"])) != "0x66eee" {
|
||||
return fmt.Errorf("invalid signatureChainId")
|
||||
}
|
||||
if strings.TrimSpace(fmt.Sprint(action["hyperliquidChain"])) != "Mainnet" {
|
||||
return fmt.Errorf("invalid hyperliquidChain")
|
||||
}
|
||||
if _, err := actionNonce(action); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSubmittedNonce(action map[string]any, submitted int64) error {
|
||||
actionValue, err := actionNonce(action)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if actionValue != submitted {
|
||||
return fmt.Errorf("nonce mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isEVMAddress(address string) bool {
|
||||
if len(address) != 42 || !strings.HasPrefix(address, "0x") {
|
||||
return false
|
||||
}
|
||||
for _, char := range address[2:] {
|
||||
if (char < '0' || char > '9') && (char < 'a' || char > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseFloatOrZero(value string) float64 {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func actionNonce(action map[string]any) (int64, error) {
|
||||
raw, ok := action["nonce"]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("missing nonce")
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case float64:
|
||||
return int64(value), nil
|
||||
case int64:
|
||||
return value, nil
|
||||
case json.Number:
|
||||
return value.Int64()
|
||||
case string:
|
||||
return strconv.ParseInt(value, 10, 64)
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid nonce")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -320,61 +321,207 @@ func (s *Server) getKlinesFromHyperliquid(symbol, interval string, limit int) ([
|
||||
return klines, nil
|
||||
}
|
||||
|
||||
func hyperliquidXYZDisplayBase(baseSymbol string) string {
|
||||
baseSymbol = strings.ToUpper(strings.TrimSpace(baseSymbol))
|
||||
// User-facing names should be product names, not exchange shorthand tickers.
|
||||
// Keep the internal symbol separate because Hyperliquid's xyz dex still routes
|
||||
// orders/candles by the short coin name (for example xyz:SMSN).
|
||||
fullNames := map[string]string{
|
||||
"XYZ100": "XYZ100",
|
||||
"TSLA": "TESLA",
|
||||
"NVDA": "NVIDIA",
|
||||
"GOLD": "GOLD",
|
||||
"HOOD": "ROBINHOOD",
|
||||
"INTC": "INTEL",
|
||||
"PLTR": "PALANTIR",
|
||||
"COIN": "COINBASE",
|
||||
"META": "META",
|
||||
"AAPL": "APPLE",
|
||||
"MSFT": "MICROSOFT",
|
||||
"ORCL": "ORACLE",
|
||||
"GOOGL": "GOOGLE",
|
||||
"AMZN": "AMAZON",
|
||||
"AMD": "AMD",
|
||||
"MU": "MICRON",
|
||||
"SNDK": "SANDISK",
|
||||
"MSTR": "MICROSTRATEGY",
|
||||
"CRCL": "CIRCLE",
|
||||
"NFLX": "NETFLIX",
|
||||
"COST": "COSTCO",
|
||||
"LLY": "ELI-LILLY",
|
||||
"SKHX": "SK-HYNIX",
|
||||
"TSM": "TSMC",
|
||||
"JPY": "JPY",
|
||||
"EUR": "EUR",
|
||||
"SILVER": "SILVER",
|
||||
"RIVN": "RIVIAN",
|
||||
"BABA": "ALIBABA",
|
||||
"CL": "CRUDE-OIL",
|
||||
"COPPER": "COPPER",
|
||||
"NATGAS": "NATURAL-GAS",
|
||||
"URANIUM": "URANIUM",
|
||||
"ALUMINIUM": "ALUMINIUM",
|
||||
"SMSN": "SAMSUNG",
|
||||
"PLATINUM": "PLATINUM",
|
||||
"USAR": "USA-RARE-EARTH",
|
||||
"CRWV": "COREWEAVE",
|
||||
"URNM": "URNM",
|
||||
"PALLADIUM": "PALLADIUM",
|
||||
"DXY": "DOLLAR-INDEX",
|
||||
"GME": "GAMESTOP",
|
||||
"KR200": "KOREA-200",
|
||||
"SOFTBANK": "SOFTBANK",
|
||||
"JP225": "JAPAN-225",
|
||||
"HYUNDAI": "HYUNDAI",
|
||||
"KIOXIA": "KIOXIA",
|
||||
"EWY": "SOUTH-KOREA-ETF",
|
||||
"EWJ": "JAPAN-ETF",
|
||||
"BRENTOIL": "BRENT-OIL",
|
||||
"VIX": "VIX",
|
||||
"HIMS": "HIMS-HERS",
|
||||
"SP500": "S&P-500",
|
||||
"DKNG": "DRAFTKINGS",
|
||||
"LITE": "LITECOIN",
|
||||
"CORN": "CORN",
|
||||
"XLE": "ENERGY-SECTOR-ETF",
|
||||
"WHEAT": "WHEAT",
|
||||
"TTF": "TTF-GAS",
|
||||
"BX": "BLACKSTONE",
|
||||
"PURRDAT": "PURRDAT",
|
||||
"MRVL": "MARVELL",
|
||||
"RKLB": "ROCKET-LAB",
|
||||
"BIRD": "BIRD",
|
||||
"VOL": "VOLATILITY",
|
||||
"DRAM": "DRAM",
|
||||
"CBRS": "COINBASE-PRE-IPO",
|
||||
"EWZ": "BRAZIL-ETF",
|
||||
"KRW": "KRW",
|
||||
"ZM": "ZOOM",
|
||||
"EBAY": "EBAY",
|
||||
"H100": "H100",
|
||||
"NIFTY": "NIFTY-50",
|
||||
"ARM": "ARM",
|
||||
"EWT": "TAIWAN-ETF",
|
||||
"GBP": "GBP",
|
||||
"SPCX": "SPACEX-PRE-IPO",
|
||||
"IBOV": "IBOVESPA",
|
||||
"ASML": "ASML",
|
||||
}
|
||||
if fullName, ok := fullNames[baseSymbol]; ok {
|
||||
return fullName
|
||||
}
|
||||
return baseSymbol
|
||||
}
|
||||
|
||||
func hyperliquidXYZCategory(baseSymbol string) string {
|
||||
baseSymbol = strings.ToUpper(strings.TrimSpace(baseSymbol))
|
||||
switch baseSymbol {
|
||||
case "GOLD", "SILVER", "CL", "COPPER", "NATGAS", "URANIUM", "ALUMINIUM", "PLATINUM", "PALLADIUM", "BRENTOIL", "CORN", "WHEAT", "TTF":
|
||||
return "commodity"
|
||||
case "XYZ100", "SP500", "JP225", "KR200", "DXY", "VIX", "XLE", "EWY", "EWJ", "EWZ", "EWT", "NIFTY", "IBOV":
|
||||
return "index"
|
||||
case "EUR", "JPY", "GBP", "KRW":
|
||||
return "forex"
|
||||
case "SPCX", "BIRD", "PURRDAT", "H100", "CBRS":
|
||||
return "pre_ipo"
|
||||
default:
|
||||
return "stock"
|
||||
}
|
||||
}
|
||||
|
||||
func hyperliquidCategoryOrder(category string) int {
|
||||
switch category {
|
||||
case "stock":
|
||||
return 0
|
||||
case "commodity":
|
||||
return 1
|
||||
case "index":
|
||||
return 2
|
||||
case "forex":
|
||||
return 3
|
||||
case "pre_ipo":
|
||||
return 4
|
||||
case "crypto":
|
||||
return 5
|
||||
default:
|
||||
return 99
|
||||
}
|
||||
}
|
||||
|
||||
// handleSymbols returns available symbols for a given exchange
|
||||
func (s *Server) handleSymbols(c *gin.Context) {
|
||||
exchange := c.DefaultQuery("exchange", "hyperliquid")
|
||||
|
||||
type SymbolInfo struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"` // crypto, stock, forex, commodity, index
|
||||
MaxLeverage int `json:"maxLeverage,omitempty"`
|
||||
Symbol string `json:"symbol"`
|
||||
Display string `json:"display"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"` // crypto, stock, forex, commodity, index
|
||||
Exchange string `json:"exchange"`
|
||||
Volume24h float64 `json:"volume_24h"`
|
||||
MarkPrice float64 `json:"mark_price"`
|
||||
PrevDayPrice float64 `json:"prev_day_price,omitempty"`
|
||||
Change24hPct float64 `json:"change_24h_pct,omitempty"`
|
||||
MaxLeverage int `json:"maxLeverage,omitempty"`
|
||||
SzDecimals int `json:"sz_decimals,omitempty"`
|
||||
}
|
||||
|
||||
var symbols []SymbolInfo
|
||||
|
||||
switch strings.ToLower(exchange) {
|
||||
exchangeLower := strings.ToLower(exchange)
|
||||
switch exchangeLower {
|
||||
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
||||
// Fetch symbols from Hyperliquid
|
||||
client := hyperliquid.NewClient()
|
||||
ctx := context.Background()
|
||||
|
||||
// Get crypto perps from default dex
|
||||
if exchange == "hyperliquid" || exchange == "hyperliquid-xyz" {
|
||||
mids, err := client.GetAllMids(ctx)
|
||||
if err == nil {
|
||||
for symbol := range mids {
|
||||
// Skip spot tokens (start with @)
|
||||
if strings.HasPrefix(symbol, "@") {
|
||||
continue
|
||||
}
|
||||
symbols = append(symbols, SymbolInfo{
|
||||
Symbol: symbol,
|
||||
Name: symbol,
|
||||
Category: "crypto",
|
||||
})
|
||||
}
|
||||
// hyperliquid-xyz returns the full USDC trading board in product order:
|
||||
// stocks → commodities → indices → forex → pre-IPO → crypto.
|
||||
if exchangeLower == "hyperliquid-xyz" || exchangeLower == "xyz" {
|
||||
xyzCoins, err := hyperliquid.GetPerpDexCoins(ctx, hyperliquid.XYZDex)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get Hyperliquid XYZ symbols", err)
|
||||
return
|
||||
}
|
||||
for _, coin := range xyzCoins {
|
||||
baseSymbol := strings.TrimPrefix(coin.Symbol, "xyz:")
|
||||
displayBase := hyperliquidXYZDisplayBase(baseSymbol)
|
||||
displaySymbol := displayBase + "-USDC"
|
||||
tradeSymbol := baseSymbol + "-USDC"
|
||||
symbols = append(symbols, SymbolInfo{
|
||||
Symbol: tradeSymbol,
|
||||
Display: displaySymbol,
|
||||
Name: displayBase,
|
||||
Category: hyperliquidXYZCategory(baseSymbol),
|
||||
Exchange: "hyperliquid-xyz",
|
||||
Volume24h: coin.Volume24h,
|
||||
MarkPrice: coin.MarkPrice,
|
||||
PrevDayPrice: coin.PrevDayPrice,
|
||||
Change24hPct: coin.Change24hPct,
|
||||
MaxLeverage: coin.MaxLeverage,
|
||||
SzDecimals: coin.SzDecimals,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get xyz dex symbols (stocks, forex, commodities)
|
||||
xyzMids, err := client.GetAllMidsXYZ(ctx)
|
||||
if err == nil {
|
||||
for symbol := range xyzMids {
|
||||
// Remove xyz: prefix for display
|
||||
displaySymbol := strings.TrimPrefix(symbol, "xyz:")
|
||||
category := "stock"
|
||||
if displaySymbol == "GOLD" || displaySymbol == "SILVER" {
|
||||
category = "commodity"
|
||||
} else if displaySymbol == "EUR" || displaySymbol == "JPY" {
|
||||
category = "forex"
|
||||
} else if displaySymbol == "XYZ100" {
|
||||
category = "index"
|
||||
}
|
||||
// Crypto perps are shown last; only include them on the combined Hyperliquid board.
|
||||
if exchangeLower == "hyperliquid" || exchangeLower == "hyperliquid-xyz" {
|
||||
coins, err := hyperliquid.GetProvider().GetAllCoins(ctx)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get Hyperliquid symbols", err)
|
||||
return
|
||||
}
|
||||
for _, coin := range coins {
|
||||
symbols = append(symbols, SymbolInfo{
|
||||
Symbol: displaySymbol,
|
||||
Name: displaySymbol,
|
||||
Category: category,
|
||||
Symbol: coin.Symbol,
|
||||
Display: coin.Symbol,
|
||||
Name: coin.Symbol,
|
||||
Category: "crypto",
|
||||
Exchange: "hyperliquid",
|
||||
Volume24h: coin.Volume24h,
|
||||
MarkPrice: coin.MarkPrice,
|
||||
PrevDayPrice: coin.PrevDayPrice,
|
||||
Change24hPct: coin.Change24hPct,
|
||||
MaxLeverage: coin.MaxLeverage,
|
||||
SzDecimals: coin.SzDecimals,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -384,6 +531,15 @@ func (s *Server) handleSymbols(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
sort.SliceStable(symbols, func(i, j int) bool {
|
||||
ci := hyperliquidCategoryOrder(symbols[i].Category)
|
||||
cj := hyperliquidCategoryOrder(symbols[j].Category)
|
||||
if ci != cj {
|
||||
return ci < cj
|
||||
}
|
||||
return symbols[i].Volume24h > symbols[j].Volume24h
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"exchange": exchange,
|
||||
"symbols": symbols,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/mcp/payment"
|
||||
"nofx/wallet"
|
||||
|
||||
gethcrypto "github.com/ethereum/go-ethereum/crypto"
|
||||
@@ -54,7 +55,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,7 +69,7 @@ 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)
|
||||
resp := beginnerOnboardingResponse{
|
||||
@@ -77,7 +78,7 @@ func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
|
||||
Chain: "base",
|
||||
Asset: "USDC",
|
||||
Provider: "claw402",
|
||||
DefaultModel: "glm-5",
|
||||
DefaultModel: payment.DefaultClaw402Model,
|
||||
ConfiguredModelID: configuredModelID,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
EnvSaved: envSaved,
|
||||
@@ -253,7 +254,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)
|
||||
|
||||
// Get symbol stats
|
||||
symbolStats, _ := store.Position().GetSymbolStats(trader.GetID(), 10)
|
||||
symbolStats, _ := store.Position().GetSymbolStatsByTraderFilters(traderIDs, traderIDPatterns, 10)
|
||||
|
||||
// Get direction stats
|
||||
directionStats, _ := store.Position().GetDirectionStats(trader.GetID())
|
||||
directionStats, _ := store.Position().GetDirectionStatsByTraderFilters(traderIDs, traderIDPatterns)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"positions": positions,
|
||||
@@ -357,8 +380,8 @@ func (s *Server) handleOrderFills(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get fills for this order
|
||||
fills, err := store.Order().GetOrderFills(orderID)
|
||||
// Get fills for this order, scoped to the trader (ownership boundary).
|
||||
fills, err := store.Order().GetOrderFills(traderID, orderID)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get order fills", err)
|
||||
return
|
||||
|
||||
@@ -14,6 +14,11 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
maxManualBTCETHLeverage = 20
|
||||
maxManualAltLeverage = 20
|
||||
)
|
||||
|
||||
// AI trader management related structures
|
||||
type CreateTraderRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
@@ -65,6 +70,24 @@ func traderCreationRequestError(reason string) string {
|
||||
return formatTraderCreationError(reason, "请检查你刚刚填写的内容后,再重新提交")
|
||||
}
|
||||
|
||||
func validateTraderLeverageRange(btcEthLeverage, altcoinLeverage int) (string, string) {
|
||||
if btcEthLeverage < 0 || btcEthLeverage > maxManualBTCETHLeverage {
|
||||
return traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_btc_eth_leverage"
|
||||
}
|
||||
if altcoinLeverage < 0 || altcoinLeverage > maxManualAltLeverage {
|
||||
return traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func isSupportedTraderSymbol(symbol string) bool {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(symbol))
|
||||
if normalized == "" {
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(normalized, "USDT") || strings.HasSuffix(normalized, "-USDC") || strings.HasPrefix(normalized, "XYZ:")
|
||||
}
|
||||
|
||||
func exchangeDisplayName(exchange *store.Exchange) string {
|
||||
if exchange == nil {
|
||||
return "所选交易所账户"
|
||||
@@ -158,12 +181,12 @@ func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string
|
||||
missing := missingExchangeFields(exchange)
|
||||
if len(missing) > 0 {
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」的配置还不完整,缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")),
|
||||
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
|
||||
), "trader.create.exchange_missing_fields", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"missing_fields", strings.Join(missing, ", "),
|
||||
)
|
||||
fmt.Sprintf("交易所账户「%s」的配置还不完整,缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")),
|
||||
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
|
||||
), "trader.create.exchange_missing_fields", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"missing_fields", strings.Join(missing, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
switch exchange.ExchangeType {
|
||||
@@ -171,12 +194,12 @@ func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string
|
||||
return "", "", nil
|
||||
default:
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
|
||||
"请改用当前版本支持的交易所账户后,再重新创建机器人",
|
||||
), "trader.create.exchange_unsupported", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"exchange_type", exchange.ExchangeType,
|
||||
)
|
||||
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
|
||||
"请改用当前版本支持的交易所账户后,再重新创建机器人",
|
||||
), "trader.create.exchange_unsupported", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"exchange_type", exchange.ExchangeType,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,24 +329,22 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate leverage values
|
||||
if req.BTCETHLeverage < 0 || req.BTCETHLeverage > 50 {
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 50 倍之间"), "trader.create.invalid_btc_eth_leverage", nil)
|
||||
return
|
||||
}
|
||||
if req.AltcoinLeverage < 0 || req.AltcoinLeverage > 20 {
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage", nil)
|
||||
// Validate leverage values against the same limits exposed by manual user config.
|
||||
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
|
||||
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate trading symbol format
|
||||
// Validate trading symbol format. Hyperliquid xyz dex markets (stocks,
|
||||
// commodities, indices, FX, Pre-IPO) are user-facing SYMBOL-USDC pairs,
|
||||
// while standard crypto/perp markets keep the legacy USDT suffix format.
|
||||
if req.TradingSymbols != "" {
|
||||
symbols := strings.Split(req.TradingSymbols, ",")
|
||||
for _, symbol := range symbols {
|
||||
symbol = strings.TrimSpace(symbol)
|
||||
if symbol != "" && !strings.HasSuffix(strings.ToUpper(symbol), "USDT") {
|
||||
if !isSupportedTraderSymbol(symbol) {
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError(
|
||||
fmt.Sprintf("交易对 %s 的格式不正确,目前只支持以 USDT 结尾的合约交易对", symbol),
|
||||
fmt.Sprintf("交易对 %s 的格式不正确,目前只支持 USDT 合约或 Hyperliquid XYZ USDC 标的(SYMBOL-USDC)", symbol),
|
||||
), "trader.create.invalid_symbol", mapStringPairs("symbol", symbol))
|
||||
return
|
||||
}
|
||||
@@ -413,8 +434,10 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
|
||||
// Set scan interval default value
|
||||
scanIntervalMinutes := req.ScanIntervalMinutes
|
||||
if scanIntervalMinutes < 3 {
|
||||
scanIntervalMinutes = 3 // Default 3 minutes, not allowed to be less than 3
|
||||
if scanIntervalMinutes <= 0 {
|
||||
scanIntervalMinutes = 15
|
||||
} else if scanIntervalMinutes < 3 {
|
||||
scanIntervalMinutes = 3 // Explicit values below 3 minutes are clamped to the minimum.
|
||||
}
|
||||
|
||||
// Query exchange actual balance, override user input
|
||||
@@ -520,14 +543,14 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
|
||||
if startupWarning == "" {
|
||||
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
|
||||
logger.Infof("⚠️ Trader %s failed to load after creation: %v", traderID, loadErr)
|
||||
logger.Infof("⚠️ Trader %s failed to load after creation: %v", traderID, loadErr)
|
||||
startupWarning = describeTraderCreationWarning(req.Name, loadErr)
|
||||
}
|
||||
}
|
||||
|
||||
if startupWarning == "" {
|
||||
if _, getErr := s.traderManager.GetTrader(traderID); getErr != nil {
|
||||
logger.Infof("⚠️ Trader %s not found in memory after creation: %v", traderID, getErr)
|
||||
logger.Infof("⚠️ Trader %s not found in memory after creation: %v", traderID, getErr)
|
||||
startupWarning = describeTraderCreationWarning(req.Name, getErr)
|
||||
}
|
||||
}
|
||||
@@ -535,11 +558,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
logger.Infof("✓ Trader created successfully: %s (model: %s, exchange: %s)", req.Name, req.AIModelID, req.ExchangeID)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"trader_id": traderID,
|
||||
"trader_name": req.Name,
|
||||
"ai_model": req.AIModelID,
|
||||
"is_running": false,
|
||||
"startup_warning": startupWarning,
|
||||
"trader_id": traderID,
|
||||
"trader_name": req.Name,
|
||||
"ai_model": req.AIModelID,
|
||||
"is_running": false,
|
||||
"startup_warning": startupWarning,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -574,6 +597,11 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if errMsg, errCode := validateTraderLeverageRange(req.BTCETHLeverage, req.AltcoinLeverage); errMsg != "" {
|
||||
SafeBadRequestWithDetails(c, errMsg, errCode, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Set default values
|
||||
isCrossMargin := existingTrader.IsCrossMargin // Keep original value
|
||||
if req.IsCrossMargin != nil {
|
||||
@@ -751,6 +779,14 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
traderName = fullCfg.Trader.Name
|
||||
}
|
||||
|
||||
if fullCfg != nil && fullCfg.Exchange != nil && fullCfg.Exchange.ExchangeType == "hyperliquid" && !fullCfg.Exchange.HyperliquidBuilderApproved {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError(
|
||||
fmt.Sprintf("机器人「%s」的 Hyperliquid 交易授权尚未完成", traderName),
|
||||
"请重新连接 Hyperliquid 钱包并完成交易授权后,再启动机器人",
|
||||
), "trader.start.hyperliquid_builder_not_approved", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if trader exists in memory and if it's running
|
||||
existingTrader, _ := s.traderManager.GetTrader(traderID)
|
||||
if existingTrader != nil {
|
||||
|
||||
@@ -267,8 +267,12 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
||||
|
||||
logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result)
|
||||
|
||||
// Record order to database (for chart markers and history)
|
||||
s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result)
|
||||
// Backfill the just-closed fill immediately. Manual closes may happen while
|
||||
// the bot runtime is stopped, so the background OrderSync loop is not enough.
|
||||
if syncErr := s.syncOrdersAfterManualClose(tempTrader, traderID, exchangeCfg.ID, exchangeCfg.ExchangeType); syncErr != nil {
|
||||
logger.Infof(" ⚠️ Manual close sync failed: %v", syncErr)
|
||||
s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Position closed successfully",
|
||||
@@ -278,6 +282,49 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) syncOrdersFromExchange(exchangeTrader trader.Trader, traderID, exchangeID, exchangeType string) error {
|
||||
switch t := exchangeTrader.(type) {
|
||||
case *binance.FuturesTrader:
|
||||
return t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, s.store)
|
||||
case *hyperliquidtrader.HyperliquidTrader:
|
||||
return t.SyncOrdersFromHyperliquid(traderID, exchangeID, exchangeType, s.store)
|
||||
case *aster.AsterTrader:
|
||||
return t.SyncOrdersFromAster(traderID, exchangeID, exchangeType, s.store)
|
||||
case *bybit.BybitTrader:
|
||||
return t.SyncOrdersFromBybit(traderID, exchangeID, exchangeType, s.store)
|
||||
case *okx.OKXTrader:
|
||||
return t.SyncOrdersFromOKX(traderID, exchangeID, exchangeType, s.store)
|
||||
case *bitget.BitgetTrader:
|
||||
return t.SyncOrdersFromBitget(traderID, exchangeID, exchangeType, s.store)
|
||||
case *gate.GateTrader:
|
||||
return t.SyncOrdersFromGate(traderID, exchangeID, exchangeType, s.store)
|
||||
case *kucoin.KuCoinTrader:
|
||||
return t.SyncOrdersFromKuCoin(traderID, exchangeID, exchangeType, s.store)
|
||||
case *lighter.LighterTraderV2:
|
||||
return t.SyncOrdersFromLighter(traderID, exchangeID, exchangeType, s.store)
|
||||
default:
|
||||
return fmt.Errorf("order sync is not available for exchange type %s", exchangeType)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) syncOrdersAfterManualClose(exchangeTrader trader.Trader, traderID, exchangeID, exchangeType string) error {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= 4; attempt++ {
|
||||
if attempt > 1 {
|
||||
time.Sleep(time.Duration(attempt-1) * 500 * time.Millisecond)
|
||||
}
|
||||
if err := s.syncOrdersFromExchange(exchangeTrader, traderID, exchangeID, exchangeType); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return fmt.Errorf("manual close sync did not run")
|
||||
}
|
||||
|
||||
// recordClosePositionOrder Record close position order to database (Lighter version - direct FILLED status)
|
||||
func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) {
|
||||
// Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates
|
||||
|
||||
28
api/handler_trader_symbol_test.go
Normal file
28
api/handler_trader_symbol_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsSupportedTraderSymbol(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
symbol string
|
||||
want bool
|
||||
}{
|
||||
{name: "legacy USDT perp", symbol: "BTCUSDT", want: true},
|
||||
{name: "legacy USDT perp lowercase", symbol: "ethusdt", want: true},
|
||||
{name: "Hyperliquid xyz stock USDC pair", symbol: "SMSN-USDC", want: true},
|
||||
{name: "Hyperliquid xyz commodity USDC pair", symbol: "GOLD-USDC", want: true},
|
||||
{name: "legacy internal xyz prefix still accepted", symbol: "xyz:SMSN", want: true},
|
||||
{name: "empty slot ignored", symbol: " ", want: true},
|
||||
{name: "bare stock without xyz prefix rejected", symbol: "SMSN", want: false},
|
||||
{name: "unknown non-USDT pair rejected", symbol: "BTCUSD", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isSupportedTraderSymbol(tt.symbol); got != tt.want {
|
||||
t.Fatalf("isSupportedTraderSymbol(%q) = %v, want %v", tt.symbol, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
17
api/handler_trader_test.go
Normal file
17
api/handler_trader_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateTraderLeverageRangeMatchesManualLimits(t *testing.T) {
|
||||
if msg, code := validateTraderLeverageRange(20, 20); msg != "" || code != "" {
|
||||
t.Fatalf("expected 20/20 leverage to be accepted, got msg=%q code=%q", msg, code)
|
||||
}
|
||||
|
||||
if msg, code := validateTraderLeverageRange(21, 20); msg == "" || code != "trader.create.invalid_btc_eth_leverage" {
|
||||
t.Fatalf("expected BTC/ETH leverage > 20 to be rejected, got msg=%q code=%q", msg, code)
|
||||
}
|
||||
|
||||
if msg, code := validateTraderLeverageRange(20, 21); msg == "" || code != "trader.create.invalid_altcoin_leverage" {
|
||||
t.Fatalf("expected altcoin leverage > 20 to be rejected, got msg=%q code=%q", msg, code)
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
Lang string `json:"lang"`
|
||||
}
|
||||
|
||||
@@ -102,9 +102,11 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Adopt orphan records from previous account (e.g. after account reset)
|
||||
// This preserves wallet keys and exchange configs so funds are not lost.
|
||||
s.adoptOrphanRecords(userID)
|
||||
// NOTE: Orphan record adoption was removed for security reasons. Previously,
|
||||
// after a reset-account call, any new user would inherit the prior owner's
|
||||
// wallet keys and exchange API credentials — a catastrophic IDOR/takeover
|
||||
// path. Operators who need to migrate credentials across users must do so
|
||||
// explicitly via export/import, never via implicit adoption on registration.
|
||||
|
||||
// Generate JWT token
|
||||
token, err := auth.GenerateJWT(user.ID, user.Email)
|
||||
@@ -127,6 +129,13 @@ func (s *Server) handleRegister(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// dummyPasswordHash is a valid bcrypt hash of a throwaway value. It is compared
|
||||
// against when the submitted email does not exist so that login takes roughly
|
||||
// the same time whether or not the account exists — closing the timing side
|
||||
// channel that would otherwise let an attacker enumerate valid emails (a fast
|
||||
// "no such user" vs. a slow bcrypt compare). It is not a secret.
|
||||
const dummyPasswordHash = "$2a$10$0iF0bCoQLJ6Ph1bF.MXwHOW.IMTxQjeEW.w38dctRQAB2kwB6ga1q"
|
||||
|
||||
// handleLogin Handle user login request
|
||||
func (s *Server) handleLogin(c *gin.Context) {
|
||||
var req struct {
|
||||
@@ -142,6 +151,9 @@ func (s *Server) handleLogin(c *gin.Context) {
|
||||
// Get user information
|
||||
user, err := s.store.User().GetByEmail(req.Email)
|
||||
if err != nil {
|
||||
// Perform a dummy comparison so the response time does not reveal
|
||||
// whether the email exists (anti user-enumeration), then fail uniformly.
|
||||
auth.CheckPassword(req.Password, dummyPasswordHash)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
|
||||
return
|
||||
}
|
||||
@@ -189,86 +201,14 @@ func (s *Server) handleChangePassword(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
|
||||
}
|
||||
|
||||
// handleResetPassword Reset password via email and new password
|
||||
func (s *Server) handleResetPassword(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid request parameters")
|
||||
return
|
||||
}
|
||||
|
||||
// Query user
|
||||
user, err := s.store.User().GetByEmail(req.Email)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Email does not exist"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new password hash
|
||||
newPasswordHash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password processing failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update password
|
||||
err = s.store.User().UpdatePassword(user.ID, newPasswordHash)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password update failed"})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("✓ User %s password has been reset", user.Email)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password reset successful, please login with new password"})
|
||||
}
|
||||
|
||||
// handleResetAccount clears user authentication data so the system returns to
|
||||
// uninitialized state for re-registration. Wallet keys (ai_models) are preserved
|
||||
// so funds are not lost — they will be adopted by the new account during onboarding.
|
||||
func (s *Server) handleResetAccount(c *gin.Context) {
|
||||
err := s.store.Transaction(func(tx *gorm.DB) error {
|
||||
// Delete traders and strategies (config, not funds)
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Trader{})
|
||||
tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.Strategy{})
|
||||
// Delete users — ai_models and exchanges are intentionally kept
|
||||
// so wallet private keys and exchange configs survive re-registration
|
||||
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&store.User{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete users: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to reset account", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("✓ User accounts cleared (wallets preserved) — system reset to uninitialized")
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Account reset successful, you can now register a new account"})
|
||||
}
|
||||
|
||||
// adoptOrphanRecords re-assigns ai_models and exchanges whose user_id no longer
|
||||
// exists in the users table. This happens after account reset so the new user
|
||||
// inherits the previous wallet keys and exchange configurations.
|
||||
func (s *Server) adoptOrphanRecords(newUserID string) {
|
||||
db := s.store.GormDB()
|
||||
result := db.Model(&store.AIModel{}).
|
||||
Where("user_id NOT IN (SELECT id FROM users)").
|
||||
Update("user_id", newUserID)
|
||||
if result.RowsAffected > 0 {
|
||||
logger.Infof("✓ Adopted %d orphan ai_model(s) for new user %s", result.RowsAffected, newUserID)
|
||||
}
|
||||
|
||||
result = db.Model(&store.Exchange{}).
|
||||
Where("user_id NOT IN (SELECT id FROM users)").
|
||||
Update("user_id", newUserID)
|
||||
if result.RowsAffected > 0 {
|
||||
logger.Infof("✓ Adopted %d orphan exchange(s) for new user %s", result.RowsAffected, newUserID)
|
||||
}
|
||||
}
|
||||
// NOTE: Password and account recovery used to live here as the public,
|
||||
// unauthenticated handlers handleResetPassword / handleResetAccount. They were
|
||||
// removed because an unauthenticated recovery endpoint is a remotely
|
||||
// exploitable auth-bypass on any public-facing deployment: the confirm phrase
|
||||
// was embedded in the frontend (and echoed back by the API), so it was friction
|
||||
// rather than authentication. Recovery now lives in the local CLI
|
||||
// (`nofx reset-password` / `nofx reset-account`, see cli.go), which requires
|
||||
// shell access to the host — something a remote attacker does not have.
|
||||
|
||||
// initUserDefaultConfigs Initialize default configs for new user
|
||||
func (s *Server) initUserDefaultConfigs(userID string, lang string) error {
|
||||
@@ -285,23 +225,17 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
name, description string
|
||||
}
|
||||
type strategyLocale struct {
|
||||
balanced, conservative, aggressive strategyI18n
|
||||
defaultStrategy strategyI18n
|
||||
}
|
||||
locales := map[string]strategyLocale{
|
||||
"zh": {
|
||||
balanced: strategyI18n{"均衡策略", "系统默认策略。均衡风险收益,适合大多数市场环境。5倍杠杆,最多3个仓位。"},
|
||||
conservative: strategyI18n{"稳健策略", "系统默认策略。低杠杆保守操作,优先保护本金。3倍杠杆,专注主流资产。"},
|
||||
aggressive: strategyI18n{"积极策略", "系统默认策略。高杠杆主动交易,更广泛的币种选择,适合经验丰富的交易者。10倍杠杆,最多5个仓位。"},
|
||||
defaultStrategy: strategyI18n{"NOFX Claw402 自动策略", "唯一内置策略:每轮读取 Claw402.ai 榜单,逐个拉取 Signal Lab 与成本/清算热力图,再结合原始 K 线决策。"},
|
||||
},
|
||||
"en": {
|
||||
balanced: strategyI18n{"Balanced Strategy", "System default strategy. Balanced risk-reward, suitable for most market conditions. 5x leverage, up to 3 positions."},
|
||||
conservative: strategyI18n{"Conservative Strategy", "System default strategy. Low-leverage conservative trading, capital preservation first. 3x leverage, focused on major assets."},
|
||||
aggressive: strategyI18n{"Aggressive Strategy", "System default strategy. High-leverage active trading, wider asset selection, for experienced traders. 10x leverage, up to 5 positions."},
|
||||
defaultStrategy: strategyI18n{"NOFX Claw402 Auto Strategy", "The only built-in strategy: read the Claw402.ai board each cycle, fetch Signal Lab and cost/liquidation heatmap per candidate, then decide with raw candles."},
|
||||
},
|
||||
"id": {
|
||||
balanced: strategyI18n{"Strategi Seimbang", "Strategi default sistem. Risiko-reward seimbang, cocok untuk sebagian besar kondisi pasar. Leverage 5x, hingga 3 posisi."},
|
||||
conservative: strategyI18n{"Strategi Konservatif", "Strategi default sistem. Trading konservatif leverage rendah, utamakan perlindungan modal. Leverage 3x, fokus aset utama."},
|
||||
aggressive: strategyI18n{"Strategi Agresif", "Strategi default sistem. Trading aktif leverage tinggi, pilihan aset lebih luas, untuk trader berpengalaman. Leverage 10x, hingga 5 posisi."},
|
||||
defaultStrategy: strategyI18n{"Strategi Otomatis NOFX Claw402", "Satu strategi bawaan: membaca papan Claw402.ai, mengambil Signal Lab dan heatmap biaya/likuidasi per kandidat, lalu memutuskan dengan candle mentah."},
|
||||
},
|
||||
}
|
||||
locale, ok := locales[lang]
|
||||
@@ -316,45 +250,42 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
applyConfig func(*store.StrategyConfig)
|
||||
}
|
||||
|
||||
setClaw402Strategy := func(c *store.StrategyConfig) {
|
||||
c.CoinSource.SourceType = "vergex_signal"
|
||||
c.CoinSource.StaticCoins = nil
|
||||
c.CoinSource.UseAI500 = false
|
||||
c.CoinSource.UseOITop = false
|
||||
c.CoinSource.UseOILow = false
|
||||
c.CoinSource.UseHyperAll = false
|
||||
c.CoinSource.UseHyperMain = false
|
||||
c.CoinSource.HyperRankCategory = "all"
|
||||
c.CoinSource.VergexLimit = 10
|
||||
c.CoinSource.VergexMarketType = "all"
|
||||
c.CoinSource.VergexChain = "hyperliquid"
|
||||
c.RiskControl.MaxPositions = 2
|
||||
c.RiskControl.BTCETHMaxLeverage = 10
|
||||
c.RiskControl.AltcoinMaxLeverage = 10
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = 10.0
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 10.0
|
||||
c.RiskControl.MaxMarginUsage = 1.0
|
||||
c.RiskControl.MinConfidence = 78
|
||||
c.RiskControl.MinRiskRewardRatio = 3.0
|
||||
c.Indicators.Klines.PrimaryTimeframe = "15m"
|
||||
c.Indicators.Klines.PrimaryCount = 30
|
||||
c.Indicators.Klines.LongerTimeframe = ""
|
||||
c.Indicators.Klines.LongerCount = 0
|
||||
c.Indicators.Klines.EnableMultiTimeframe = false
|
||||
c.Indicators.Klines.SelectedTimeframes = []string{"15m"}
|
||||
c.Indicators.EnableRawKlines = true
|
||||
}
|
||||
|
||||
definitions := []strategyDef{
|
||||
{
|
||||
name: locale.balanced.name,
|
||||
description: locale.balanced.description,
|
||||
name: locale.defaultStrategy.name,
|
||||
description: locale.defaultStrategy.description,
|
||||
isActive: true,
|
||||
applyConfig: func(c *store.StrategyConfig) {
|
||||
// Uses default config as-is
|
||||
},
|
||||
},
|
||||
{
|
||||
name: locale.conservative.name,
|
||||
description: locale.conservative.description,
|
||||
isActive: false,
|
||||
applyConfig: func(c *store.StrategyConfig) {
|
||||
c.RiskControl.BTCETHMaxLeverage = 3
|
||||
c.RiskControl.AltcoinMaxLeverage = 3
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = 3.0
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 0.5
|
||||
c.RiskControl.MinConfidence = 80
|
||||
c.RiskControl.MinRiskRewardRatio = 4.0
|
||||
c.Indicators.Klines.SelectedTimeframes = []string{"15m", "1h", "4h"}
|
||||
c.Indicators.Klines.PrimaryTimeframe = "15m"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: locale.aggressive.name,
|
||||
description: locale.aggressive.description,
|
||||
isActive: false,
|
||||
applyConfig: func(c *store.StrategyConfig) {
|
||||
c.RiskControl.BTCETHMaxLeverage = 10
|
||||
c.RiskControl.AltcoinMaxLeverage = 7
|
||||
c.RiskControl.MaxPositions = 5
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 2.0
|
||||
c.RiskControl.MinConfidence = 70
|
||||
c.CoinSource.AI500Limit = 5
|
||||
c.CoinSource.UseOITop = true
|
||||
c.CoinSource.OITopLimit = 5
|
||||
c.Indicators.Klines.SelectedTimeframes = []string{"3m", "15m", "1h"}
|
||||
c.Indicators.Klines.PrimaryTimeframe = "3m"
|
||||
setClaw402Strategy(c)
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -370,6 +301,7 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
for _, def := range definitions {
|
||||
config := store.GetDefaultStrategyConfig(configLang)
|
||||
def.applyConfig(&config)
|
||||
config.ClampLimits()
|
||||
|
||||
strategy := &store.Strategy{
|
||||
ID: uuid.New().String(),
|
||||
@@ -385,11 +317,49 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
strategies = append(strategies, strategy)
|
||||
}
|
||||
|
||||
legacyDefaultNames := []string{
|
||||
"均衡策略", "稳健策略", "积极策略",
|
||||
"美股趋势策略", "美股稳健策略", "美股突破策略",
|
||||
"Balanced Strategy", "Conservative Strategy", "Aggressive Strategy",
|
||||
"US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy",
|
||||
"Strategi Seimbang", "Strategi Konservatif", "Strategi Agresif",
|
||||
"Strategi Tren Saham AS", "Strategi Stabil Saham AS", "Strategi Breakout Saham AS",
|
||||
}
|
||||
|
||||
return s.store.Transaction(func(tx *gorm.DB) error {
|
||||
// Remove obsolete built-in risk-profile presets for this user. If a trader still
|
||||
// references one of them, keep it to avoid breaking an existing running setup.
|
||||
deleteResult := tx.Where("user_id = ? AND name IN ? AND id NOT IN (SELECT strategy_id FROM traders WHERE user_id = ? AND strategy_id IS NOT NULL)", userID, legacyDefaultNames, userID).
|
||||
Delete(&store.Strategy{})
|
||||
if deleteResult.Error != nil {
|
||||
return fmt.Errorf("failed to remove legacy default strategies: %w", deleteResult.Error)
|
||||
}
|
||||
if deleteResult.RowsAffected > 0 {
|
||||
logger.Infof(" ✓ Removed %d legacy default strategy preset(s)", deleteResult.RowsAffected)
|
||||
}
|
||||
|
||||
var activeCount int64
|
||||
if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND is_active = ?", userID, true).Count(&activeCount).Error; err != nil {
|
||||
return fmt.Errorf("failed to count active strategies: %w", err)
|
||||
}
|
||||
|
||||
for _, strategy := range strategies {
|
||||
var existing int64
|
||||
if err := tx.Model(&store.Strategy{}).Where("user_id = ? AND name = ?", userID, strategy.Name).Count(&existing).Error; err != nil {
|
||||
return fmt.Errorf("failed to check strategy %q: %w", strategy.Name, err)
|
||||
}
|
||||
if existing > 0 {
|
||||
continue
|
||||
}
|
||||
if activeCount > 0 {
|
||||
strategy.IsActive = false
|
||||
}
|
||||
if err := tx.Create(strategy).Error; err != nil {
|
||||
return fmt.Errorf("failed to create strategy %q: %w", strategy.Name, err)
|
||||
}
|
||||
if strategy.IsActive {
|
||||
activeCount++
|
||||
}
|
||||
logger.Infof(" ✓ Created default strategy: %s (active=%v)", strategy.Name, strategy.IsActive)
|
||||
}
|
||||
return nil
|
||||
|
||||
136
api/handler_user_default_strategy_test.go
Normal file
136
api/handler_user_default_strategy_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
func TestCreateDefaultStrategiesUsesOneReadyToRunClaw402Preset(t *testing.T) {
|
||||
st, err := store.New(t.TempDir() + "/nofx.db")
|
||||
if err != nil {
|
||||
t.Fatalf("store.New failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
s := &Server{store: st}
|
||||
userID := "user-us-stock-presets"
|
||||
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
|
||||
t.Fatalf("createDefaultStrategies failed: %v", err)
|
||||
}
|
||||
|
||||
strategies, err := st.Strategy().List(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("List strategies failed: %v", err)
|
||||
}
|
||||
if len(strategies) != 1 {
|
||||
t.Fatalf("expected 1 default strategy, got %d", len(strategies))
|
||||
}
|
||||
|
||||
byName := map[string]*store.Strategy{}
|
||||
activeCount := 0
|
||||
for _, strategy := range strategies {
|
||||
byName[strategy.Name] = strategy
|
||||
if strategy.IsActive {
|
||||
activeCount++
|
||||
}
|
||||
if strategy.Name == "均衡策略" || strategy.Name == "稳健策略" || strategy.Name == "积极策略" {
|
||||
t.Fatalf("legacy crypto-style default strategy still present: %s", strategy.Name)
|
||||
}
|
||||
}
|
||||
if activeCount != 1 {
|
||||
t.Fatalf("expected exactly one active strategy, got %d", activeCount)
|
||||
}
|
||||
|
||||
defaultStrategy := byName["NOFX Claw402 自动策略"]
|
||||
if defaultStrategy == nil || !defaultStrategy.IsActive {
|
||||
t.Fatalf("NOFX Claw402 自动策略 should exist and be active")
|
||||
}
|
||||
trendCfg, err := defaultStrategy.ParseConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("default ParseConfig failed: %v", err)
|
||||
}
|
||||
if trendCfg.CoinSource.SourceType != "vergex_signal" || trendCfg.CoinSource.VergexLimit != 10 || trendCfg.CoinSource.VergexMarketType != "all" {
|
||||
t.Fatalf("default strategy should use the Claw402/Vergex all-market signal ranking, got %+v", trendCfg.CoinSource)
|
||||
}
|
||||
if trendCfg.CoinSource.UseAI500 || trendCfg.RiskControl.MaxPositions > 2 {
|
||||
t.Fatalf("default strategy should be Claw402/Vergex native with at most two positions, got coin=%+v risk=%+v", trendCfg.CoinSource, trendCfg.RiskControl)
|
||||
}
|
||||
if trendCfg.RiskControl.BTCETHMaxLeverage != 10 || trendCfg.RiskControl.AltcoinMaxLeverage != 10 {
|
||||
t.Fatalf("default strategy should use 10x leverage for all Claw402 opens, got risk=%+v", trendCfg.RiskControl)
|
||||
}
|
||||
if trendCfg.RiskControl.BTCETHMaxPositionValueRatio != 10 ||
|
||||
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 10 ||
|
||||
trendCfg.RiskControl.MaxMarginUsage != 1.0 {
|
||||
t.Fatalf("default strategy should use full-size 10x notional for Claw402 opens, got risk=%+v", trendCfg.RiskControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDefaultStrategiesMigratesLegacyPresetsWithoutOverridingActiveCustom(t *testing.T) {
|
||||
st, err := store.New(t.TempDir() + "/nofx.db")
|
||||
if err != nil {
|
||||
t.Fatalf("store.New failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
userID := "user-existing-custom"
|
||||
legacyCfg := store.GetDefaultStrategyConfig("zh")
|
||||
legacy := &store.Strategy{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: "均衡策略",
|
||||
Description: "legacy",
|
||||
IsActive: false,
|
||||
}
|
||||
if err := legacy.SetConfig(&legacyCfg); err != nil {
|
||||
t.Fatalf("legacy SetConfig failed: %v", err)
|
||||
}
|
||||
if err := st.Strategy().Create(legacy); err != nil {
|
||||
t.Fatalf("create legacy failed: %v", err)
|
||||
}
|
||||
|
||||
custom := &store.Strategy{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: "aa",
|
||||
Description: "user custom active strategy",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := custom.SetConfig(&legacyCfg); err != nil {
|
||||
t.Fatalf("custom SetConfig failed: %v", err)
|
||||
}
|
||||
if err := st.Strategy().Create(custom); err != nil {
|
||||
t.Fatalf("create custom failed: %v", err)
|
||||
}
|
||||
|
||||
s := &Server{store: st}
|
||||
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
|
||||
t.Fatalf("createDefaultStrategies failed: %v", err)
|
||||
}
|
||||
if err := s.createDefaultStrategies(userID, "zh"); err != nil {
|
||||
t.Fatalf("second createDefaultStrategies should be idempotent: %v", err)
|
||||
}
|
||||
|
||||
strategies, err := st.Strategy().List(userID)
|
||||
if err != nil {
|
||||
t.Fatalf("List strategies failed: %v", err)
|
||||
}
|
||||
byName := map[string]int{}
|
||||
activeNames := []string{}
|
||||
for _, strategy := range strategies {
|
||||
byName[strategy.Name]++
|
||||
if strategy.IsActive {
|
||||
activeNames = append(activeNames, strategy.Name)
|
||||
}
|
||||
}
|
||||
if byName["均衡策略"] != 0 {
|
||||
t.Fatalf("legacy preset should be removed, got names=%+v", byName)
|
||||
}
|
||||
if byName["NOFX Claw402 自动策略"] != 1 {
|
||||
t.Fatalf("expected exactly one NOFX Claw402 自动策略, got names=%+v", byName)
|
||||
}
|
||||
if len(activeNames) != 1 || activeNames[0] != "aa" {
|
||||
t.Fatalf("existing active custom strategy should stay the only active one, got %+v", activeNames)
|
||||
}
|
||||
}
|
||||
115
api/handler_vergex.go
Normal file
115
api/handler_vergex.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"nofx/provider/vergex"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (s *Server) handleVergexSignalRanking(c *gin.Context) {
|
||||
client, ok := s.newVergexClientForRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := client.GetSignalRanking(context.Background(), vergex.Query{
|
||||
Chain: strings.TrimSpace(c.Query("chain")),
|
||||
LiqBand: strings.TrimSpace(c.Query("liqBand")),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warnf("Vergex signal-ranking failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
limit := parsePositiveInt(c.Query("limit"), vergex.MaxSignalRankingItems)
|
||||
marketType := strings.TrimSpace(c.Query("marketType"))
|
||||
items := vergex.FilterSignalRankingItems(data.Items, marketType, limit)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": items,
|
||||
"raw": data.Raw,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleVergexSignalLab(c *gin.Context) {
|
||||
client, ok := s.newVergexClientForRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := client.GetSignalLab(context.Background(), vergex.Query{
|
||||
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
|
||||
Symbol: strings.TrimSpace(c.Query("symbol")),
|
||||
Chain: strings.TrimSpace(c.Query("chain")),
|
||||
LiqBand: strings.TrimSpace(c.Query("liqBand")),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warnf("Vergex signal-lab failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
||||
}
|
||||
|
||||
func (s *Server) handleVergexCostLiquidationHeatmap(c *gin.Context) {
|
||||
client, ok := s.newVergexClientForRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := client.GetCostLiquidationHeatmap(context.Background(), vergex.Query{
|
||||
MarketType: withDefault(strings.TrimSpace(c.Query("marketType")), vergex.DefaultMarketType),
|
||||
Symbol: strings.TrimSpace(c.Query("symbol")),
|
||||
Chain: strings.TrimSpace(c.Query("chain")),
|
||||
LiqBand: strings.TrimSpace(c.Query("liqBand")),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warnf("Vergex cost-liquidation-heatmap failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
||||
}
|
||||
|
||||
func (s *Server) newVergexClientForRequest(c *gin.Context) (*vergex.Client, bool) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return nil, false
|
||||
}
|
||||
walletKey, err := s.resolveStrategyDataWalletKey(userID, c.Query("ai_model_id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return nil, false
|
||||
}
|
||||
if walletKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "claw402 wallet is not configured"})
|
||||
return nil, false
|
||||
}
|
||||
client, err := vergex.NewClient("", walletKey, &logger.MCPLogger{})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return nil, false
|
||||
}
|
||||
return client, true
|
||||
}
|
||||
|
||||
func parsePositiveInt(raw string, fallback int) int {
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil || n <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func withDefault(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
101
api/ratelimit.go
Normal file
101
api/ratelimit.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"math"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ipRateLimiter is a small, dependency-free token-bucket rate limiter keyed by
|
||||
// client IP. It is used to throttle the unauthenticated auth endpoints
|
||||
// (login / register) against online brute-force attacks.
|
||||
//
|
||||
// Design notes:
|
||||
// - Per-IP token bucket with lazy refill (no background goroutine).
|
||||
// - Idle buckets are evicted opportunistically so a flood of distinct source
|
||||
// IPs (e.g. spoofed X-Forwarded-For) cannot grow the map without bound.
|
||||
// - This is a throttle, not an authenticator. Behind a reverse proxy the
|
||||
// effective key is whatever gin's ClientIP() resolves; operators who
|
||||
// terminate TLS at a proxy should configure trusted proxies so ClientIP()
|
||||
// reflects the real peer rather than a spoofable header.
|
||||
type ipRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*rlBucket
|
||||
rate float64 // tokens added per second
|
||||
burst float64 // maximum tokens (and initial fill)
|
||||
lastGC time.Time
|
||||
}
|
||||
|
||||
type rlBucket struct {
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
// newIPRateLimiter creates a limiter that allows bursts up to `burst` requests
|
||||
// and then refills at `ratePerSec` tokens/second per client IP.
|
||||
func newIPRateLimiter(ratePerSec, burst float64) *ipRateLimiter {
|
||||
return &ipRateLimiter{
|
||||
buckets: make(map[string]*rlBucket),
|
||||
rate: ratePerSec,
|
||||
burst: burst,
|
||||
}
|
||||
}
|
||||
|
||||
// allow reports whether a request from key is permitted at time now, consuming
|
||||
// one token when it is.
|
||||
func (l *ipRateLimiter) allow(key string, now time.Time) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
// Opportunistic GC: drop buckets idle for >10 minutes. Bounds memory even
|
||||
// under a spoofed-IP flood without needing a background goroutine.
|
||||
if l.lastGC.IsZero() {
|
||||
l.lastGC = now
|
||||
}
|
||||
if now.Sub(l.lastGC) > time.Minute {
|
||||
for k, b := range l.buckets {
|
||||
if now.Sub(b.last) > 10*time.Minute {
|
||||
delete(l.buckets, k)
|
||||
}
|
||||
}
|
||||
l.lastGC = now
|
||||
}
|
||||
|
||||
b, ok := l.buckets[key]
|
||||
if !ok {
|
||||
b = &rlBucket{tokens: l.burst, last: now}
|
||||
l.buckets[key] = b
|
||||
}
|
||||
|
||||
// Refill based on elapsed time, capped at burst.
|
||||
elapsed := now.Sub(b.last).Seconds()
|
||||
if elapsed > 0 {
|
||||
b.tokens = math.Min(l.burst, b.tokens+elapsed*l.rate)
|
||||
b.last = now
|
||||
}
|
||||
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
// rateLimitMiddleware throttles requests per client IP, returning 429 when the
|
||||
// caller exceeds the configured rate.
|
||||
func rateLimitMiddleware(l *ipRateLimiter) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !l.allow(c.ClientIP(), time.Now()) {
|
||||
c.Header("Retry-After", "60")
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"error": "Too many requests. Please slow down and try again in a minute.",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
54
api/ratelimit_test.go
Normal file
54
api/ratelimit_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIPRateLimiterBurstThenThrottle verifies that a client gets `burst`
|
||||
// immediate attempts and is then throttled until tokens refill.
|
||||
func TestIPRateLimiterBurstThenThrottle(t *testing.T) {
|
||||
// 1 token/sec, burst of 3.
|
||||
l := newIPRateLimiter(1.0, 3)
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
|
||||
// First 3 requests in the same instant are allowed (the burst).
|
||||
for i := 0; i < 3; i++ {
|
||||
if !l.allow("1.2.3.4", now) {
|
||||
t.Fatalf("request %d in burst should be allowed", i+1)
|
||||
}
|
||||
}
|
||||
// 4th in the same instant is throttled.
|
||||
if l.allow("1.2.3.4", now) {
|
||||
t.Fatalf("request beyond burst should be throttled")
|
||||
}
|
||||
|
||||
// After 1 second, one token refills → exactly one more request allowed.
|
||||
now = now.Add(time.Second)
|
||||
if !l.allow("1.2.3.4", now) {
|
||||
t.Fatalf("one token should have refilled after 1s")
|
||||
}
|
||||
if l.allow("1.2.3.4", now) {
|
||||
t.Fatalf("only one token should refill per second")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIPRateLimiterIsolatesClients verifies one IP exhausting its bucket does
|
||||
// not throttle a different IP.
|
||||
func TestIPRateLimiterIsolatesClients(t *testing.T) {
|
||||
l := newIPRateLimiter(1.0, 2)
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
|
||||
// Exhaust IP A.
|
||||
if !l.allow("10.0.0.1", now) || !l.allow("10.0.0.1", now) {
|
||||
t.Fatalf("IP A burst should be allowed")
|
||||
}
|
||||
if l.allow("10.0.0.1", now) {
|
||||
t.Fatalf("IP A should be throttled after burst")
|
||||
}
|
||||
|
||||
// IP B is unaffected.
|
||||
if !l.allow("10.0.0.2", now) {
|
||||
t.Fatalf("IP B should be allowed regardless of IP A")
|
||||
}
|
||||
}
|
||||
158
api/server.go
158
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,10 @@ func (s *Server) setupRoutes() {
|
||||
// Server IP query (requires authentication, for whitelist configuration)
|
||||
s.route(protected, "GET", "/server-ip", "Get server public IP (for exchange whitelist)", s.handleGetServerIP)
|
||||
|
||||
s.route(protected, "GET", "/vergex/signal-ranking", "Vergex signal ranking via claw402 (?marketType=all&limit=30)", s.handleVergexSignalRanking)
|
||||
s.route(protected, "GET", "/vergex/signal-lab", "Vergex signal lab via claw402 (?marketType=hip3_perp&symbol=AAPL)", s.handleVergexSignalLab)
|
||||
s.route(protected, "GET", "/vergex/cost-liquidation-heatmap", "Vergex cost/liquidation heatmap via claw402 (?marketType=hip3_perp&symbol=AAPL)", s.handleVergexCostLiquidationHeatmap)
|
||||
|
||||
// AI trader management
|
||||
s.routeWithSchema(protected, "GET", "/my-traders", "List user's traders with status",
|
||||
`Returns: [{"trader_id":"<EXACT id — use this as trader_id in all ?trader_id= queries and POST /traders/:id/start|stop>","trader_name":"<string>","is_running":<bool>}]
|
||||
@@ -145,7 +224,7 @@ NOTE: The id field is "trader_id" (NOT "id"). Always read trader_id from this en
|
||||
`:id = trader_id from GET /api/my-traders`,
|
||||
s.handleGetTraderConfig)
|
||||
s.routeWithSchema(protected, "POST", "/traders", "Create a new AI trader",
|
||||
`Body: {"name":"<string, required>","ai_model_id":"<EXACT id field from GET /api/models — e.g. 'abc123_deepseek', NOT the provider name 'deepseek'>","exchange_id":"<EXACT id field from GET /api/exchanges — e.g. '05785d3b-841e-...', NOT the type name>","strategy_id":"<EXACT id field from GET /api/strategies>","scan_interval_minutes":<int, default 3, minimum 3>}
|
||||
`Body: {"name":"<string, required>","ai_model_id":"<EXACT id field from GET /api/models — e.g. 'abc123_deepseek', NOT the provider name 'deepseek'>","exchange_id":"<EXACT id field from GET /api/exchanges — e.g. '05785d3b-841e-...', NOT the type name>","strategy_id":"<EXACT id field from GET /api/strategies>","scan_interval_minutes":<int, default 15, minimum 3>}
|
||||
IMPORTANT: ai_model_id and exchange_id must be the full "id" value from the Account State, not the provider/type name.`,
|
||||
s.handleCreateTrader)
|
||||
s.routeWithSchema(protected, "PUT", "/traders/:id", "Update trader configuration",
|
||||
@@ -256,10 +335,10 @@ CRITICAL: Always use the "id" field for strategy_id.`,
|
||||
IMPORTANT: For most use cases just POST {"name":"<name>"} — the backend fills everything in. Only include "config" when the user explicitly requests custom settings (specific coins, custom leverage, custom timeframes).
|
||||
|
||||
StrategyConfig fields:
|
||||
coin_source.source_type: "static"(fixed coin list) | "ai500"(AI top500 ranking) | "oi_top"(OI increasing, suited for long) | "oi_low"(OI decreasing, suited for short) | "mixed"
|
||||
coin_source.static_coins: ["BTCUSDT","ETHUSDT"] — only when source_type="static"
|
||||
coin_source.use_ai500, ai500_limit: number of coins from AI500 pool (default 10)
|
||||
coin_source.use_oi_top/use_oi_low, oi_top_limit/oi_low_limit: OI-based coin selection
|
||||
coin_source.source_type: "vergex_signal" (Claw402/Vergex signal-ranking; default and recommended)
|
||||
coin_source.vergex_limit: number of Claw402 candidates enriched with detail data (default 10, max 10)
|
||||
coin_source.vergex_market_type: "all" for the full Claw402 board; detail calls use each ranking item's market_type
|
||||
coin_source.vergex_chain: "hyperliquid"
|
||||
indicators.klines.primary_timeframe: "1m"|"3m"|"5m"|"15m"|"1h"|"4h" — scalping→"5m", trend/swing→"1h"/"4h"
|
||||
indicators.klines.primary_count: number of candles (20-100)
|
||||
indicators.klines.enable_multi_timeframe: true for trend/swing analysis
|
||||
@@ -508,34 +587,45 @@ func isPrivateIP(ip net.IP) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// getTraderFromQuery Get trader from query parameter
|
||||
// getTraderFromQuery resolves a trader from the ?trader_id= query parameter,
|
||||
// strictly scoped to the authenticated caller.
|
||||
//
|
||||
// Ownership is always enforced against the caller's own trader list in the
|
||||
// store. We deliberately never fall back to the global in-memory trader map
|
||||
// (TraderManager holds every account's traders): returning an entry from it for
|
||||
// a trader the caller does not own is a cross-tenant data leak (IDOR) — a
|
||||
// freshly-registered user with no traders of their own could otherwise pass any
|
||||
// other account's trader_id and read its balance, positions and AI decisions.
|
||||
func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, string, error) {
|
||||
userID := c.GetString("user_id")
|
||||
traderID := c.Query("trader_id")
|
||||
|
||||
// Ensure user's traders are loaded into memory
|
||||
err := s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||||
if err != nil {
|
||||
// Ensure user's traders are loaded into memory.
|
||||
if err := s.traderManager.LoadUserTradersFromStore(s.store, userID); err != nil {
|
||||
logger.Infof("⚠️ Failed to load traders for user %s: %v", userID, err)
|
||||
}
|
||||
|
||||
if traderID == "" {
|
||||
// If no trader_id specified, return first trader for this user
|
||||
ids := s.traderManager.GetTraderIDs()
|
||||
if len(ids) == 0 {
|
||||
return nil, "", fmt.Errorf("No available traders")
|
||||
}
|
||||
|
||||
// Get user's trader list, prioritize returning user's own traders
|
||||
userTraders, err := s.store.Trader().List(userID)
|
||||
if err == nil && len(userTraders) > 0 {
|
||||
traderID = userTraders[0].ID
|
||||
} else {
|
||||
traderID = ids[0]
|
||||
}
|
||||
// Resolve strictly from the caller's own trader list.
|
||||
userTraders, err := s.store.Trader().List(userID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to load traders for this account: %w", err)
|
||||
}
|
||||
if len(userTraders) == 0 {
|
||||
return nil, "", fmt.Errorf("No available traders")
|
||||
}
|
||||
|
||||
return s.traderManager, traderID, nil
|
||||
if traderID == "" {
|
||||
// No trader_id specified — default to the caller's first trader.
|
||||
return s.traderManager, userTraders[0].ID, nil
|
||||
}
|
||||
|
||||
// A trader_id was supplied — it must belong to the caller.
|
||||
for _, t := range userTraders {
|
||||
if t.ID == traderID {
|
||||
return s.traderManager, traderID, nil
|
||||
}
|
||||
}
|
||||
return nil, "", fmt.Errorf("trader not found for this account")
|
||||
}
|
||||
|
||||
// authMiddleware JWT authentication middleware
|
||||
|
||||
@@ -2,11 +2,38 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"nofx/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestPublicDecryptRouteNotRegistered is a security regression test: the
|
||||
// unauthenticated POST /api/crypto/decrypt route was a decryption oracle and
|
||||
// must never be re-registered. A built server's router must not route to it.
|
||||
func TestPublicDecryptRouteNotRegistered(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
s := &Server{router: gin.New()}
|
||||
s.setupRoutes()
|
||||
|
||||
for _, r := range s.router.Routes() {
|
||||
if r.Method == http.MethodPost && r.Path == "/api/crypto/decrypt" {
|
||||
t.Fatalf("SECURITY REGRESSION: public decryption oracle POST /api/crypto/decrypt is registered")
|
||||
}
|
||||
}
|
||||
|
||||
// Also assert at the HTTP layer that the route is not handled.
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/crypto/decrypt", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 for POST /api/crypto/decrypt, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateTraderRequest_SystemPromptTemplate Test whether SystemPromptTemplate field exists when updating trader
|
||||
func TestUpdateTraderRequest_SystemPromptTemplate(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -20,6 +21,9 @@ import (
|
||||
// validateStrategyConfig validates strategy configuration and returns warnings
|
||||
func validateStrategyConfig(config *store.StrategyConfig) []string {
|
||||
var warnings []string
|
||||
if config.StrategyType == "grid_trading" {
|
||||
return warnings
|
||||
}
|
||||
|
||||
// Validate NofxOS API key if any NofxOS feature is enabled
|
||||
if (config.Indicators.EnableQuantData || config.Indicators.EnableOIRanking ||
|
||||
@@ -31,6 +35,17 @@ func validateStrategyConfig(config *store.StrategyConfig) []string {
|
||||
return warnings
|
||||
}
|
||||
|
||||
func attachPublishConfig(config *store.StrategyConfig, strategy *store.Strategy) {
|
||||
if config == nil || strategy == nil {
|
||||
return
|
||||
}
|
||||
config.ClampLimits()
|
||||
config.PublishConfig = &store.PublishStrategyConfig{
|
||||
IsPublic: strategy.IsPublic,
|
||||
ConfigVisible: strategy.ConfigVisible,
|
||||
}
|
||||
}
|
||||
|
||||
// handleEstimateTokens estimates token usage for a strategy config (no auth required, pure computation)
|
||||
func (s *Server) handleEstimateTokens(c *gin.Context) {
|
||||
var req struct {
|
||||
@@ -71,6 +86,7 @@ func (s *Server) handlePublicStrategies(c *gin.Context) {
|
||||
if st.ConfigVisible {
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(st.Config), &config)
|
||||
attachPublishConfig(&config, st)
|
||||
item["config"] = config
|
||||
}
|
||||
|
||||
@@ -90,6 +106,14 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
lang := c.Query("lang")
|
||||
if lang == "" {
|
||||
lang = "zh"
|
||||
}
|
||||
if err := s.createDefaultStrategies(userID, lang); err != nil {
|
||||
logger.Warnf("Failed to sync default strategy presets for user %s: %v", userID, err)
|
||||
}
|
||||
|
||||
strategies, err := s.store.Strategy().List(userID)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Failed to get strategy list", err)
|
||||
@@ -101,6 +125,7 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
|
||||
for _, st := range strategies {
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(st.Config), &config)
|
||||
attachPublishConfig(&config, st)
|
||||
|
||||
result = append(result, gin.H{
|
||||
"id": st.ID,
|
||||
@@ -139,6 +164,7 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
|
||||
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(strategy.Config), &config)
|
||||
attachPublishConfig(&config, strategy)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": strategy.ID,
|
||||
@@ -162,10 +188,12 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
|
||||
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Lang string `json:"lang"` // "zh" or "en", used when config is omitted
|
||||
Config *store.StrategyConfig `json:"config"` // optional — uses default if omitted
|
||||
IsPublic bool `json:"is_public"`
|
||||
ConfigVisible bool `json:"config_visible"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -182,6 +210,19 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
defaultCfg := store.GetDefaultStrategyConfig(lang)
|
||||
req.Config = &defaultCfg
|
||||
}
|
||||
beforeClamp := *req.Config
|
||||
req.Config.ClampLimits()
|
||||
hadPublishConfig := req.Config.PublishConfig != nil
|
||||
isPublic := req.IsPublic
|
||||
configVisible := req.ConfigVisible
|
||||
if hadPublishConfig {
|
||||
isPublic = req.Config.PublishConfig.IsPublic
|
||||
configVisible = req.Config.PublishConfig.ConfigVisible
|
||||
}
|
||||
req.Config.PublishConfig = &store.PublishStrategyConfig{
|
||||
IsPublic: isPublic,
|
||||
ConfigVisible: configVisible,
|
||||
}
|
||||
|
||||
// Serialize configuration
|
||||
configJSON, err := json.Marshal(req.Config)
|
||||
@@ -197,7 +238,10 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
Description: req.Description,
|
||||
IsActive: false,
|
||||
IsDefault: false,
|
||||
Config: string(configJSON),
|
||||
IsPublic: isPublic,
|
||||
// Existing default is true; keep that behavior when no explicit publish config is sent.
|
||||
ConfigVisible: configVisible || !hadPublishConfig,
|
||||
Config: string(configJSON),
|
||||
}
|
||||
|
||||
if err := s.store.Strategy().Create(strategy); err != nil {
|
||||
@@ -207,6 +251,7 @@ func (s *Server) handleCreateStrategy(c *gin.Context) {
|
||||
|
||||
// Validate configuration and collect warnings
|
||||
warnings := validateStrategyConfig(req.Config)
|
||||
warnings = append(warnings, store.StrategyClampWarnings(beforeClamp, *req.Config, req.Config.Language)...)
|
||||
|
||||
response := gin.H{
|
||||
"id": strategy.ID,
|
||||
@@ -263,14 +308,21 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
mergedConfig = store.StrategyConfig{}
|
||||
}
|
||||
|
||||
// Apply incoming config on top: top-level sections present in the request overwrite
|
||||
// their corresponding existing section; absent sections remain unchanged.
|
||||
// Apply incoming config on top while preserving nested fields that were not sent.
|
||||
if len(req.Config) > 0 && string(req.Config) != "null" {
|
||||
if err := json.Unmarshal(req.Config, &mergedConfig); err != nil {
|
||||
var patch map[string]any
|
||||
if err := json.Unmarshal(req.Config, &patch); err != nil {
|
||||
SafeBadRequest(c, "Invalid config JSON")
|
||||
return
|
||||
}
|
||||
mergedConfig, err = store.MergeStrategyConfig(mergedConfig, patch)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid config JSON")
|
||||
return
|
||||
}
|
||||
}
|
||||
beforeClamp := mergedConfig
|
||||
mergedConfig.ClampLimits()
|
||||
|
||||
// Preserve existing name/description when not supplied
|
||||
name := req.Name
|
||||
@@ -324,6 +376,7 @@ func (s *Server) handleUpdateStrategy(c *gin.Context) {
|
||||
|
||||
// Validate merged configuration and collect warnings
|
||||
warnings := validateStrategyConfig(&mergedConfig)
|
||||
warnings = append(warnings, store.StrategyClampWarnings(beforeClamp, mergedConfig, mergedConfig.Language)...)
|
||||
|
||||
response := gin.H{"message": "Strategy updated successfully"}
|
||||
if len(warnings) > 0 {
|
||||
@@ -417,6 +470,7 @@ func (s *Server) handleGetActiveStrategy(c *gin.Context) {
|
||||
|
||||
var config store.StrategyConfig
|
||||
json.Unmarshal([]byte(strategy.Config), &config)
|
||||
attachPublishConfig(&config, strategy)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": strategy.ID,
|
||||
@@ -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,12 +282,16 @@ func isEncryptedStorageValue(value string) bool {
|
||||
}
|
||||
|
||||
func (cs *CryptoService) DecryptPayload(payload *EncryptedPayload) ([]byte, error) {
|
||||
// 1. Validate timestamp (prevent replay attacks)
|
||||
if payload.TS != 0 {
|
||||
elapsed := time.Since(time.Unix(payload.TS, 0))
|
||||
if elapsed > 5*time.Minute || elapsed < -1*time.Minute {
|
||||
return nil, errors.New("timestamp invalid or expired")
|
||||
}
|
||||
// 1. Validate timestamp (prevent replay attacks).
|
||||
// The timestamp is mandatory: a missing/zero ts previously skipped this check
|
||||
// entirely, which let a captured ciphertext be replayed indefinitely. The
|
||||
// client (web/src/lib/crypto.ts) always stamps ts, so requiring it is safe.
|
||||
if payload.TS == 0 {
|
||||
return nil, errors.New("missing timestamp")
|
||||
}
|
||||
elapsed := time.Since(time.Unix(payload.TS, 0))
|
||||
if elapsed > 5*time.Minute || elapsed < -1*time.Minute {
|
||||
return nil, errors.New("timestamp invalid or expired")
|
||||
}
|
||||
|
||||
// 2. Decode base64url
|
||||
@@ -455,8 +459,11 @@ func (es EncryptedString) Value() (driver.Value, error) {
|
||||
if globalCryptoService != nil {
|
||||
encrypted, err := globalCryptoService.EncryptForStorage(string(es))
|
||||
if err != nil {
|
||||
// If encryption fails, return the original value
|
||||
return string(es), nil
|
||||
// Fail closed: never silently persist a plaintext secret when
|
||||
// encryption was expected to happen. Returning the error aborts the
|
||||
// write so a misconfigured/broken crypto service cannot leak
|
||||
// credentials into the database in cleartext.
|
||||
return nil, fmt.Errorf("failed to encrypt sensitive field for storage: %w", err)
|
||||
}
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
@@ -9,8 +9,14 @@ services:
|
||||
stop_grace_period: 30s # Allow the app 30 seconds for graceful shutdown
|
||||
ports:
|
||||
- "${NOFX_BACKEND_PORT:-8080}:8080"
|
||||
- "6060:6060" # pprof profiling
|
||||
# pprof profiling is bound to host loopback only; uncomment for local debug.
|
||||
# - "127.0.0.1:6060:6060"
|
||||
volumes:
|
||||
# NOTE: .env is bind-mounted so the beginner-onboarding flow
|
||||
# (persistBeginnerWalletEnv) can write CLAW402_WALLET_* back to the host
|
||||
# file. Without this mount the wallet is regenerated on every container
|
||||
# restart. For threat models where the .env file should not be reachable
|
||||
# via container RCE, deploy via env vars only and remove this mount.
|
||||
- ./.env:/app/.env
|
||||
- ./data:/app/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
|
||||
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 预检查阶段
|
||||
- 如果后续需要,支持一个用户多条并发执行会话
|
||||
@@ -12,8 +12,11 @@ NOFX 文档提供多种语言版本。
|
||||
|----------|-------------|--------|-------------|
|
||||
| 🇬🇧 **English** | [README.md](../../README.md) | ✅ Complete | Core Team |
|
||||
| 🇨🇳 **Chinese (中文)** | [README.md](zh-CN/README.md) | ✅ Complete | Community |
|
||||
| 🇯🇵 **Japanese (日本語)** | [README.md](ja/README.md) | ✅ Complete | Community |
|
||||
| 🇰🇷 **Korean (한국어)** | [README.md](ko/README.md) | ✅ Complete | Community |
|
||||
| 🇷🇺 **Russian (Русский)** | [README.md](ru/README.md) | ✅ Complete | Community |
|
||||
| 🇺🇦 **Ukrainian (Українська)** | [README.md](uk/README.md) | ✅ Complete | Community |
|
||||
| 🇻🇳 **Vietnamese (Tiếng Việt)** | [README.md](vi/README.md) | ✅ Complete | Community |
|
||||
|
||||
---
|
||||
|
||||
@@ -30,6 +33,16 @@ NOFX 文档提供多种语言版本。
|
||||
- **安全政策:** [../../SECURITY.md](../../SECURITY.md#中文)
|
||||
- **常见问题:** [../guides/faq.zh-CN.md](../guides/faq.zh-CN.md)
|
||||
|
||||
### 日本語 🇯🇵
|
||||
- **メイン README:** [ja/README.md](ja/README.md)
|
||||
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
- **Security:** [../../SECURITY.md](../../SECURITY.md)
|
||||
|
||||
### 한국어 🇰🇷
|
||||
- **메인 README:** [ko/README.md](ko/README.md)
|
||||
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
- **Security:** [../../SECURITY.md](../../SECURITY.md)
|
||||
|
||||
### Русский 🇷🇺
|
||||
- **Основной README:** [ru/README.md](ru/README.md)
|
||||
- **Руководство по участию:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
@@ -40,6 +53,11 @@ NOFX 文档提供多种语言版本。
|
||||
- **Посібник із внесків:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
- **Політика безпеки:** [../../SECURITY.md](../../SECURITY.md)
|
||||
|
||||
### Tiếng Việt 🇻🇳
|
||||
- **README chính:** [vi/README.md](vi/README.md)
|
||||
- **Contributing:** [../../CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
- **Security:** [../../SECURITY.md](../../SECURITY.md)
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Help with Translations / 帮助翻译
|
||||
@@ -49,7 +67,7 @@ NOFX 文档提供多种语言版本。
|
||||
We welcome translation contributions! / 我们欢迎翻译贡献!
|
||||
|
||||
**What needs translation? / 需要翻译什么?**
|
||||
- ✅ Main README (complete for 4 languages)
|
||||
- ✅ Main README (complete for 7 languages)
|
||||
- 🚧 Deployment guides (partial)
|
||||
- 📋 User guides (needed)
|
||||
- 📋 Contributing guide (needed for RU/UK)
|
||||
@@ -94,10 +112,11 @@ faq.zh-CN.md → Chinese FAQ
|
||||
**Language Codes:**
|
||||
- `en` - English (default, no suffix needed)
|
||||
- `zh-CN` - Simplified Chinese
|
||||
- `ja` - Japanese
|
||||
- `ko` - Korean
|
||||
- `ru` - Russian
|
||||
- `uk` - Ukrainian
|
||||
- `ja` - Japanese *(future)*
|
||||
- `ko` - Korean *(future)*
|
||||
- `vi` - Vietnamese
|
||||
|
||||
### Quality Standards / 质量标准
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a> によるバックアップ</strong></p>
|
||||
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>あなた専属の AI トレーディングアシスタント。</strong><br/>
|
||||
<strong>あらゆる市場。あらゆるモデル。API キー不要、USDC で支払い。</strong>
|
||||
<strong>グローバル市場向け AI トレーディングターミナル。</strong><br/>
|
||||
<strong>米国株、コモディティ、FX、暗号資産のリサーチ、戦略生成、執行、モニタリング。</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,8 +17,6 @@
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,92 +31,127 @@
|
||||
|
||||
---
|
||||
|
||||
NOFX はオープンソースの**自律型** AI トレーディングアシスタントです。従来の AI ツールのように手動でモデルを設定し、API キーを管理し、データソースを接続する必要はありません — NOFX の AI は**市場を自ら認識し、モデルを自ら選択し、データを自ら取得します**。人間の介入はゼロ。あなたは戦略を設定するだけ、残りは AI が処理します。
|
||||
NOFX は、マーケットリサーチ、戦略開発、取引執行、ポートフォリオ監視をひとつのワークスペースで行うためのオープンソース AI トレーディングターミナルです。
|
||||
|
||||
**完全自律**: AI がどのモデルを使うか、どの市場データを取得するか、いつ取引するかを自ら判断します。手動のモデル設定不要。複数サービスの API キー管理不要。USDC ウォレットに入金して実行するだけ。
|
||||
|
||||
他との違い:**[x402](https://x402.org) マイクロペイメント内蔵**。API キー不要。USDC ウォレットに入金してリクエストごとに支払い。ウォレットがあなたの身分証明。
|
||||
対象は米国株、コモディティ契約、FX ペア、デジタル資産などの高流動性グローバル市場です。AI レイヤーは取引意図をウォッチリスト、シグナル、戦略ロジック、リスク制御、執行ワークフローへ変換します。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
**http://127.0.0.1:3000** を開く。完了。
|
||||
**http://127.0.0.1:3000** を開きます。
|
||||
|
||||
---
|
||||
|
||||
## x402 の仕組み
|
||||
## 取引所登録
|
||||
|
||||
従来のフロー:アカウント登録 → クレジット購入 → API キー取得 → クォータ管理 → キーのローテーション。
|
||||
以下のリンクから、暗号資産および対応する米国株、FX、コモディティデリバティブ市場向けの取引口座を開設できます。これらは NOFX のパートナープログラム経由で、手数料割引または紹介特典が適用される場合があります。
|
||||
|
||||
x402 フロー:
|
||||
| 取引所 | 状態 | 手数料割引付き登録 |
|
||||
| :--- | :---: | :--- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [登録](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [登録](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [登録](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [登録](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
```
|
||||
リクエスト → 402(価格提示)→ ウォレットが USDC を署名 → リトライ → 完了
|
||||
```
|
||||
---
|
||||
|
||||
アカウント不要。API キー不要。前払いクレジット不要。ウォレット1つで全モデル。
|
||||
## クイックデモ
|
||||
|
||||
### 内蔵 x402 プロバイダー
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
| プロバイダー | チェーン | モデル |
|
||||
|:---------|:------|:-------|
|
||||
| <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+ モデル |
|
||||
<p align="center">
|
||||
カバー画像をクリックしてデモ動画をご覧ください。
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 市場
|
||||
|
||||
**米国株 · コモディティ · FX · 暗号資産**
|
||||
|
||||
NOFX は単一取引所の画面ではなく、マルチアセットのリサーチ、戦略構築、執行、監視ワークフローを中心に設計されています。
|
||||
|
||||
---
|
||||
|
||||
## AI モデルアクセス
|
||||
|
||||
NOFX は AI 推論を [Claw402](https://claw402.ai) 経由で自動ルーティングします。ユーザーはモデルプロバイダーの設定、API キー管理、個別 AI アカウントの維持を行う必要がありません。ターミナルは Claw402 の従量課金インフラを使って対応モデルへオンデマンドにアクセスし、公式割引チャネルを通じてルーティングします。
|
||||
|
||||
| プロバイダー | アクセス |
|
||||
| :--- | :--- |
|
||||
| **Claw402** | [公式割引で従量課金 AI モデルにアクセス](https://claw402.ai) |
|
||||
|
||||
---
|
||||
|
||||
## 機能
|
||||
|
||||
| 機能 | 説明 |
|
||||
|:--------|:------------|
|
||||
| **マルチ AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — いつでも切替 |
|
||||
| **マルチ取引所** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **ストラテジースタジオ** | ビジュアルビルダー — コインソース、インジケーター、リスク管理 |
|
||||
| **AI ディベートアリーナ** | 複数 AI が取引を議論(ブル vs ベア vs アナリスト)、投票、実行 |
|
||||
| **AI 競争** | AI がリアルタイムで競争、リーダーボードで成績ランキング |
|
||||
| **Telegram エージェント** | トレーディングアシスタントとチャット — ストリーミング、ツール呼び出し、メモリ |
|
||||
| **バックテストラボ** | 過去データシミュレーション、エクイティカーブと成績指標 |
|
||||
| **ダッシュボード** | ライブポジション、損益、Chain of Thought 付き AI 判断ログ |
|
||||
| :--- | :--- |
|
||||
| **AI トレーディングターミナル** | 米国株、コモディティ、FX、暗号資産向けの統合ワークスペース |
|
||||
| **AI モデルアクセス** | Claw402 経由で対応プロバイダーへ自動接続 |
|
||||
| **取引所接続** | Binance、Bybit、OKX、Hyperliquid、Bitget、KuCoin、Gate、Aster、Lighter |
|
||||
| **Strategy Studio** | 市場ユニバース、インジケーター、リスク制御、戦略ロジック |
|
||||
| **モデル競争** | AI トレーダーのライブ成績とランキングを比較 |
|
||||
| **Telegram Agent** | チャットからトレーディングアシスタントを操作・監視 |
|
||||
| **ポートフォリオダッシュボード** | ポジション、損益、執行履歴、モデル判断ログ |
|
||||
|
||||
### 市場
|
||||
---
|
||||
|
||||
暗号通貨 · 米国株 · FX · 貴金属
|
||||
## スクリーンショット
|
||||
|
||||
### 取引所 (CEX)
|
||||
<details>
|
||||
<summary><b>設定ページ</b></summary>
|
||||
|
||||
| 取引所 | ステータス | 登録 (手数料割引) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [登録](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [登録](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [登録](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| 設定 | トレーダー一覧 |
|
||||
| :----------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
|
||||
|
||||
### 取引所 (Perp-DEX)
|
||||
</details>
|
||||
|
||||
| 取引所 | ステータス | 登録 (手数料割引) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [登録](https://app.lighter.xyz/?referral=68151432) |
|
||||
<details>
|
||||
<summary><b>ダッシュボード</b></summary>
|
||||
|
||||
### AI モデル (API キーモード)
|
||||
| 概要 | マーケットチャート |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| AI モデル | ステータス | API キー取得 |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [API キー取得](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [API キー取得](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [API キー取得](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [API キー取得](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [API キー取得](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [API キー取得](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [API キー取得](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [API キー取得](https://platform.minimaxi.com) |
|
||||
| 取引統計 | ポジション履歴 |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
### AI モデル (x402 モード — API キー不要)
|
||||
| ポジション | トレーダー詳細 |
|
||||
| :----------------------------------------------------------: | :---------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
|
||||
|
||||
15+ モデルを [Claw402](https://claw402.ai) 経由で利用 — USDC ウォレットのみ
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Strategy Studio</b></summary>
|
||||
|
||||
| 戦略エディタ | インジケーター設定 |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>コンペティション</b></summary>
|
||||
|
||||
| コンペティションモード |
|
||||
| :-------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -128,7 +163,7 @@ x402 フロー:
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (クラウド)
|
||||
### Railway(クラウド)
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
@@ -139,33 +174,154 @@ curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### ソースから
|
||||
### Windows
|
||||
|
||||
[Docker Desktop](https://www.docker.com/products/docker-desktop/) をインストールしてから:
|
||||
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### ソースからビルド
|
||||
|
||||
```bash
|
||||
# 前提条件: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# Prerequisites: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # バックエンド
|
||||
cd web && npm install && npm run dev # フロントエンド(新しいターミナル)
|
||||
go build -o nofx && ./nofx
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
### アップデート
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## セットアップ
|
||||
|
||||
**初心者モード**:ガイド付き onboarding により、モデルアクセス、取引所接続、戦略設定、初回デプロイまで進められます。
|
||||
|
||||
**上級モード**:
|
||||
|
||||
1. AI モデルアクセスを設定
|
||||
2. 取引所の認証情報を接続
|
||||
3. 戦略を構築またはインポート
|
||||
4. AI トレーダープロファイルを作成
|
||||
5. ダッシュボードから起動、監視、改善
|
||||
|
||||
すべての設定は Web UI **http://127.0.0.1:3000** から行えます。
|
||||
|
||||
---
|
||||
|
||||
## サーバーへのデプロイ
|
||||
|
||||
**HTTP デプロイ:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000 でアクセス
|
||||
```
|
||||
|
||||
**Cloudflare 経由の HTTPS:**
|
||||
|
||||
1. [Cloudflare](https://dash.cloudflare.com)(無料プラン)にドメインを追加
|
||||
2. A レコードをサーバー IP に設定(Proxied)
|
||||
3. SSL/TLS を Flexible に設定
|
||||
4. `.env` に `TRANSPORT_ENCRYPTION=true` を設定
|
||||
|
||||
---
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ドキュメント
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| [アーキテクチャ](../../architecture/README.md) | システム設計とモジュール索引 |
|
||||
| [戦略モジュール](../../architecture/STRATEGY_MODULE.md) | 銘柄選択、AI プロンプト、執行 |
|
||||
| [FAQ](../../faq/README.md) | よくある質問 |
|
||||
| [はじめに](../../getting-started/README.md) | デプロイガイド |
|
||||
|
||||
---
|
||||
|
||||
## 貢献
|
||||
|
||||
[貢献ガイド](../../../CONTRIBUTING.md)、[行動規範](../../../CODE_OF_CONDUCT.md)、[セキュリティポリシー](../../../SECURITY.md) を参照してください。
|
||||
|
||||
### 貢献者プログラム
|
||||
|
||||
NOFX は有意義な貢献を記録し、エコシステムの成長に応じて貢献者へ還元する予定です。優先 Issue は高い報酬ウェイトを持ちます。
|
||||
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## リンク
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| ウェブサイト | [nofxai.com](https://nofxai.com) |
|
||||
| ダッシュボード | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API ドキュメント | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| :--- | :--- |
|
||||
| Website | [vergex.trade](https://vergex.trade) |
|
||||
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
|
||||
|
||||
> **リスク警告**: AI 自動取引には重大なリスクがあります。学習/研究目的または少額でのテストのみを推奨します。
|
||||
> **リスク警告**:自動売買には大きなリスクがあります。適切なポジションサイズを守り、各取引所の仕組みを理解し、失ってもよい資金だけを使用してください。
|
||||
|
||||
---
|
||||
|
||||
## スポンサー
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
[スポンサーになる](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a>가 지원합니다</strong></p>
|
||||
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>당신만의 AI 트레이딩 어시스턴트.</strong><br/>
|
||||
<strong>모든 시장. 모든 모델. API 키 없이 USDC로 결제.</strong>
|
||||
<strong>글로벌 시장을 위한 AI 트레이딩 터미널.</strong><br/>
|
||||
<strong>미국 주식, 원자재, 외환, 암호화폐 리서치, 전략 생성, 실행, 모니터링.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,8 +17,6 @@
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,92 +31,127 @@
|
||||
|
||||
---
|
||||
|
||||
NOFX는 오픈소스 **자율형** AI 트레이딩 어시스턴트입니다. 수동으로 모델을 설정하고, API 키를 관리하고, 데이터 소스를 연결해야 하는 기존 AI 도구와 달리 — NOFX의 AI는 **시장을 스스로 인식하고, 모델을 스스로 선택하고, 데이터를 스스로 가져옵니다**. 인간 개입 제로. 전략만 설정하면 나머지는 AI가 처리합니다.
|
||||
NOFX는 시장 리서치, 전략 개발, 거래 실행, 포트폴리오 모니터링을 하나의 워크스페이스에서 처리하는 오픈소스 AI 트레이딩 터미널입니다.
|
||||
|
||||
**완전 자율**: AI가 어떤 모델을 사용할지, 어떤 시장 데이터를 가져올지, 언제 거래할지를 스스로 결정합니다. 수동 모델 설정 불필요. 여러 서비스의 API 키 관리 불필요. USDC 지갑에 충전하고 실행하기만 하면 됩니다.
|
||||
|
||||
차별점: **[x402](https://x402.org) 마이크로 결제 내장**. API 키 불필요. USDC 지갑에 충전하고 요청마다 결제. 지갑이 곧 신원.
|
||||
제품은 미국 주식, 원자재 계약, FX 페어, 디지털 자산 등 글로벌 유동성 시장을 중심으로 설계되었습니다. AI 레이어는 거래 의도를 워치리스트, 신호, 전략 로직, 리스크 제어, 실행 워크플로로 변환합니다.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
**http://127.0.0.1:3000** 을 열면 완료.
|
||||
**http://127.0.0.1:3000** 을 엽니다.
|
||||
|
||||
---
|
||||
|
||||
## x402 작동 방식
|
||||
## 거래소 등록
|
||||
|
||||
기존 플로우: 계정 등록 → 크레딧 구매 → API 키 받기 → 쿼터 관리 → 키 교체.
|
||||
아래 링크를 통해 암호화폐와 지원되는 미국 주식, FX, 원자재 파생상품 시장용 거래 계정을 개설할 수 있습니다. 이 링크는 NOFX 파트너 프로그램을 통해 제공되며 수수료 할인 또는 추천 혜택이 포함될 수 있습니다.
|
||||
|
||||
x402 플로우:
|
||||
| 거래소 | 상태 | 수수료 할인 등록 |
|
||||
| :--- | :---: | :--- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [등록](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [등록](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [등록](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [등록](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [등록](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [등록](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
```
|
||||
요청 → 402 (가격 제시) → 지갑이 USDC 서명 → 재시도 → 완료
|
||||
```
|
||||
---
|
||||
|
||||
계정 불필요. API 키 불필요. 선불 크레딧 불필요. 지갑 하나로 모든 모델.
|
||||
## 빠른 데모
|
||||
|
||||
### 내장 x402 프로바이더
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
| 프로바이더 | 체인 | 모델 |
|
||||
|:---------|:------|:-------|
|
||||
| <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+ 모델 |
|
||||
<p align="center">
|
||||
커버 이미지를 클릭해 데모 영상을 보세요.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 시장
|
||||
|
||||
**미국 주식 · 원자재 · 외환 · 암호화폐**
|
||||
|
||||
NOFX는 단일 거래소 화면이 아니라 멀티에셋 리서치, 전략 구축, 실행, 모니터링 워크플로를 중심으로 구성됩니다.
|
||||
|
||||
---
|
||||
|
||||
## AI 모델 액세스
|
||||
|
||||
NOFX는 AI 추론을 [Claw402](https://claw402.ai)를 통해 자동 라우팅합니다. 사용자는 모델 제공업체를 설정하거나 API 키를 관리하거나 별도 AI 계정을 유지할 필요가 없습니다. 터미널은 Claw402의 사용량 기반 인프라를 통해 지원 모델에 온디맨드로 접근하며 공식 할인 채널로 트래픽을 라우팅합니다.
|
||||
|
||||
| 제공업체 | 액세스 |
|
||||
| :--- | :--- |
|
||||
| **Claw402** | [공식 할인으로 사용량 기반 AI 모델 이용](https://claw402.ai) |
|
||||
|
||||
---
|
||||
|
||||
## 기능
|
||||
|
||||
| 기능 | 설명 |
|
||||
|:--------|:------------|
|
||||
| **멀티 AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — 언제든 전환 |
|
||||
| **멀티 거래소** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **전략 스튜디오** | 비주얼 빌더 — 코인 소스, 지표, 리스크 관리 |
|
||||
| **AI 토론 아레나** | 여러 AI가 거래 토론 (강세 vs 약세 vs 분석가), 투표, 실행 |
|
||||
| **AI 경쟁** | AI가 실시간 경쟁, 리더보드 순위 |
|
||||
| **Telegram 에이전트** | 트레이딩 어시스턴트와 채팅 — 스트리밍, 도구 호출, 메모리 |
|
||||
| **백테스트 랩** | 과거 시뮬레이션, 자산 곡선 및 성과 지표 |
|
||||
| **대시보드** | 실시간 포지션, 손익, Chain of Thought AI 결정 로그 |
|
||||
| :--- | :--- |
|
||||
| **AI 트레이딩 터미널** | 미국 주식, 원자재, 외환, 암호화폐 워크플로를 위한 통합 워크스페이스 |
|
||||
| **AI 모델 액세스** | Claw402를 통해 지원 모델 제공업체에 자동 연결 |
|
||||
| **거래소 연결** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, Lighter |
|
||||
| **Strategy Studio** | 시장 유니버스, 지표, 리스크 제어, 전략 로직 |
|
||||
| **모델 경쟁** | AI 트레이더의 실시간 성과와 리더보드 비교 |
|
||||
| **Telegram Agent** | 채팅으로 트레이딩 어시스턴트 제어 및 모니터링 |
|
||||
| **포트폴리오 대시보드** | 포지션, 손익, 실행 기록, 모델 의사결정 로그 |
|
||||
|
||||
### 시장
|
||||
---
|
||||
|
||||
암호화폐 · 미국 주식 · 외환 · 귀금속
|
||||
## 스크린샷
|
||||
|
||||
### 거래소 (CEX)
|
||||
<details>
|
||||
<summary><b>설정 페이지</b></summary>
|
||||
|
||||
| 거래소 | 상태 | 등록 (수수료 할인) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [등록](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [등록](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [등록](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [등록](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [등록](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| 설정 | 트레이더 목록 |
|
||||
| :----------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
|
||||
|
||||
### 거래소 (Perp-DEX)
|
||||
</details>
|
||||
|
||||
| 거래소 | 상태 | 등록 (수수료 할인) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [등록](https://app.lighter.xyz/?referral=68151432) |
|
||||
<details>
|
||||
<summary><b>대시보드</b></summary>
|
||||
|
||||
### AI 모델 (API 키 모드)
|
||||
| 개요 | 마켓 차트 |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| AI 모델 | 상태 | API 키 받기 |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [API 키 받기](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [API 키 받기](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [API 키 받기](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [API 키 받기](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [API 키 받기](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [API 키 받기](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [API 키 받기](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [API 키 받기](https://platform.minimaxi.com) |
|
||||
| 거래 통계 | 포지션 기록 |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
### AI 모델 (x402 모드 — API 키 불필요)
|
||||
| 포지션 | 트레이더 상세 |
|
||||
| :----------------------------------------------------------: | :---------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
|
||||
|
||||
15+ 모델을 [Claw402](https://claw402.ai)로 이용 — USDC 지갑만 있으면 됩니다
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Strategy Studio</b></summary>
|
||||
|
||||
| 전략 에디터 | 지표 설정 |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>경쟁</b></summary>
|
||||
|
||||
| 경쟁 모드 |
|
||||
| :-------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -128,7 +163,7 @@ x402 플로우:
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (클라우드)
|
||||
### Railway(클라우드)
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
@@ -139,33 +174,154 @@ curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 소스에서
|
||||
### Windows
|
||||
|
||||
[Docker Desktop](https://www.docker.com/products/docker-desktop/) 설치 후:
|
||||
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 소스에서 빌드
|
||||
|
||||
```bash
|
||||
# 필수 조건: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# Prerequisites: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # 백엔드
|
||||
cd web && npm install && npm run dev # 프론트엔드 (새 터미널)
|
||||
go build -o nofx && ./nofx
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
### 업데이트
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 설정
|
||||
|
||||
**초보자 모드**: 가이드 온보딩이 모델 액세스, 거래소 연결, 전략 설정, 첫 배포까지 안내합니다.
|
||||
|
||||
**고급 모드**:
|
||||
|
||||
1. AI 모델 액세스 설정
|
||||
2. 거래소 인증 정보 연결
|
||||
3. 전략 생성 또는 가져오기
|
||||
4. AI 트레이더 프로필 생성
|
||||
5. 대시보드에서 실행, 모니터링, 개선
|
||||
|
||||
모든 설정은 Web UI **http://127.0.0.1:3000** 에서 가능합니다.
|
||||
|
||||
---
|
||||
|
||||
## 서버에 배포
|
||||
|
||||
**HTTP 배포:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000 으로 접근
|
||||
```
|
||||
|
||||
**Cloudflare를 통한 HTTPS:**
|
||||
|
||||
1. [Cloudflare](https://dash.cloudflare.com)(무료 플랜)에 도메인 추가
|
||||
2. A 레코드를 서버 IP로 지정(Proxied)
|
||||
3. SSL/TLS를 Flexible로 설정
|
||||
4. `.env`에 `TRANSPORT_ENCRYPTION=true` 설정
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 문서
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| [아키텍처](../../architecture/README.md) | 시스템 설계와 모듈 색인 |
|
||||
| [전략 모듈](../../architecture/STRATEGY_MODULE.md) | 종목 선택, AI 프롬프트, 실행 |
|
||||
| [FAQ](../../faq/README.md) | 자주 묻는 질문 |
|
||||
| [시작하기](../../getting-started/README.md) | 배포 가이드 |
|
||||
|
||||
---
|
||||
|
||||
## 기여
|
||||
|
||||
[기여 가이드](../../../CONTRIBUTING.md), [행동 강령](../../../CODE_OF_CONDUCT.md), [보안 정책](../../../SECURITY.md)을 확인하세요.
|
||||
|
||||
### 기여자 프로그램
|
||||
|
||||
NOFX는 의미 있는 기여를 기록하며 생태계 성장에 따라 기여자에게 보상할 계획입니다. 우선순위 이슈는 더 높은 보상 가중치를 가집니다.
|
||||
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## 링크
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| 웹사이트 | [nofxai.com](https://nofxai.com) |
|
||||
| 대시보드 | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API 문서 | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| :--- | :--- |
|
||||
| Website | [vergex.trade](https://vergex.trade) |
|
||||
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
|
||||
|
||||
> **위험 경고**: AI 자동 거래에는 상당한 위험이 있습니다. 학습/연구 또는 소액 테스트만 권장합니다.
|
||||
> **위험 고지**: 자동매매에는 상당한 위험이 따릅니다. 적절한 포지션 규모를 사용하고 각 거래소 구조를 이해하며 감당 가능한 자금만 거래하세요.
|
||||
|
||||
---
|
||||
|
||||
## 스폰서
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
[스폰서 되기](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<p align="center"><strong>При поддержке <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
||||
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Ваш персональный AI торговый ассистент.</strong><br/>
|
||||
<strong>Любой рынок. Любая модель. Оплата USDC, без API ключей.</strong>
|
||||
<strong>AI-терминал для глобальных рынков.</strong><br/>
|
||||
<strong>Исследования, генерация стратегий, исполнение и мониторинг для акций США, сырьевых товаров, FX и криптоактивов.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,8 +17,6 @@
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,92 +31,127 @@
|
||||
|
||||
---
|
||||
|
||||
NOFX — это **автономный** AI торговый ассистент с открытым исходным кодом. В отличие от традиционных AI инструментов, где нужно вручную настраивать модели, управлять API ключами и подключать источники данных — AI в NOFX **сам анализирует рынки, выбирает модели и получает данные**. Нулевое вмешательство человека. Вы задаёте стратегию, AI делает всё остальное.
|
||||
NOFX — open-source AI-терминал для активных трейдеров, которым нужен единый рабочий контур для анализа рынков, разработки стратегий, исполнения сделок и мониторинга портфеля.
|
||||
|
||||
**Полная автономность**: AI сам решает, какую модель использовать, какие рыночные данные получить, когда торговать. Без ручной настройки моделей. Без жонглирования API ключами разных сервисов. Просто пополните USDC кошелёк и запустите.
|
||||
|
||||
Ключевое отличие: **встроенные [x402](https://x402.org) микроплатежи**. Без API ключей. Пополните USDC кошелёк и платите за каждый запрос. Кошелёк — это ваша идентификация.
|
||||
Продукт ориентирован на ликвидные глобальные рынки: акции США, товарные контракты, валютные пары и цифровые активы. AI-слой превращает торговое намерение в списки наблюдения, сигналы, стратегическую логику, риск-контроль и рабочие процессы исполнения.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Откройте **http://127.0.0.1:3000**. Готово.
|
||||
**http://127.0.0.1:3000** 을 엽니다.
|
||||
|
||||
---
|
||||
|
||||
## Как работает x402
|
||||
## Регистрация на биржах
|
||||
|
||||
Традиционный процесс: регистрация → покупка кредитов → получение API ключа → управление квотой → ротация ключей.
|
||||
Используйте ссылки ниже для открытия торговых счетов на крипторынках и поддерживаемых рынках деривативов на акции США, FX и сырьевые товары. Эти маршруты относятся к партнерским программам NOFX и могут включать скидки на комиссии или реферальные преимущества.
|
||||
|
||||
x402 процесс:
|
||||
| Биржа | Статус | Регистрация со скидкой |
|
||||
| :--- | :---: | :--- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Регистрация](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Регистрация](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Регистрация](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Регистрация](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Регистрация](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Регистрация](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Регистрация](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Регистрация](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Регистрация](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
```
|
||||
Запрос → 402 (вот цена) → кошелёк подписывает USDC → повтор → готово
|
||||
```
|
||||
---
|
||||
|
||||
Без аккаунтов. Без API ключей. Без предоплаты. Один кошелёк, все модели.
|
||||
## Короткая демонстрация
|
||||
|
||||
### Встроенные x402 провайдеры
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
| Провайдер | Сеть | Модели |
|
||||
|:---------|:------|:-------|
|
||||
| <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+ моделей |
|
||||
<p align="center">
|
||||
Нажмите на обложку, чтобы посмотреть демо.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Рынки
|
||||
|
||||
**Акции США · Сырьевые товары · FX · Криптоактивы**
|
||||
|
||||
NOFX организует исследования, построение стратегий, исполнение и мониторинг вокруг мультиактивных рабочих процессов, а не вокруг одного торгового экрана.
|
||||
|
||||
---
|
||||
|
||||
## Доступ к AI-моделям
|
||||
|
||||
NOFX автоматически маршрутизирует AI-инференс через [Claw402](https://claw402.ai). Пользователям не нужно настраивать провайдеров моделей, управлять API-ключами или поддерживать отдельные AI-аккаунты. Терминал обращается к поддерживаемым моделям по требованию через pay-as-you-go инфраструктуру Claw402 и официальный канал со скидкой.
|
||||
|
||||
| Провайдер | Доступ |
|
||||
| :--- | :--- |
|
||||
| **Claw402** | [Доступ к AI-моделям по мере использования с официальной скидкой](https://claw402.ai) |
|
||||
|
||||
---
|
||||
|
||||
## Возможности
|
||||
|
||||
| Функция | Описание |
|
||||
|:--------|:------------|
|
||||
| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — переключение в любой момент |
|
||||
| **Мульти-биржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Студия стратегий** | Визуальный конструктор — источники монет, индикаторы, контроль рисков |
|
||||
| **AI Арена дебатов** | Несколько AI обсуждают сделки (Бык vs Медведь vs Аналитик), голосуют, исполняют |
|
||||
| **AI Соревнование** | AI соревнуются в реальном времени, рейтинг в таблице лидеров |
|
||||
| **Telegram Агент** | Чат с торговым ассистентом — стриминг, вызов инструментов, память |
|
||||
| **Лаборатория бэктеста** | Историческая симуляция с кривой капитала и метриками |
|
||||
| **Панель управления** | Позиции в реальном времени, P/L, логи AI решений с Chain of Thought |
|
||||
| Возможность | Описание |
|
||||
| :--- | :--- |
|
||||
| **AI trading terminal** | Единое рабочее пространство для акций США, сырья, FX и криптоактивов |
|
||||
| **Доступ к AI-моделям** | Автоматический доступ к поддерживаемым провайдерам через Claw402 |
|
||||
| **Подключение бирж** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, Lighter |
|
||||
| **Strategy Studio** | Рыночные универсумы, индикаторы, риск-контроль и логика стратегий |
|
||||
| **Соревнование моделей** | Сравнение AI-трейдеров по live-результатам и таблице лидеров |
|
||||
| **Telegram Agent** | Управление и мониторинг ассистента через чат |
|
||||
| **Портфельный дашборд** | Позиции, P/L, история исполнения и логи решений модели |
|
||||
|
||||
### Рынки
|
||||
---
|
||||
|
||||
Криптовалюта · Акции США · Форекс · Металлы
|
||||
## Скриншоты
|
||||
|
||||
### Биржи (CEX)
|
||||
<details>
|
||||
<summary><b>Страница настроек</b></summary>
|
||||
|
||||
| Биржа | Статус | Регистрация (скидка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Регистрация](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Регистрация](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Регистрация](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Регистрация](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Регистрация](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Регистрация](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| Конфигурация | Список трейдеров |
|
||||
| :----------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
|
||||
|
||||
### Биржи (Perp-DEX)
|
||||
</details>
|
||||
|
||||
| Биржа | Статус | Регистрация (скидка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Регистрация](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Регистрация](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Регистрация](https://app.lighter.xyz/?referral=68151432) |
|
||||
<details>
|
||||
<summary><b>Дашборд</b></summary>
|
||||
|
||||
### AI Модели (Режим API ключей)
|
||||
| Обзор | График рынка |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| AI Модель | Статус | Получить API ключ |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Получить](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Получить](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Получить](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Получить](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Получить](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Получить](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Получить](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Получить](https://platform.minimaxi.com) |
|
||||
| Статистика торгов | История позиций |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
### AI Модели (Режим x402 — без API ключей)
|
||||
| Позиции | Детали трейдера |
|
||||
| :----------------------------------------------------------: | :---------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
|
||||
|
||||
15+ моделей через [Claw402](https://claw402.ai) — только USDC кошелёк
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Strategy Studio</b></summary>
|
||||
|
||||
| Редактор стратегий | Настройка индикаторов |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Соревнование</b></summary>
|
||||
|
||||
| Режим соревнования |
|
||||
| :-------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -128,7 +163,7 @@ x402 процесс:
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (Облако)
|
||||
### Railway (облако)
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
@@ -139,35 +174,155 @@ curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Из исходников
|
||||
### Windows
|
||||
|
||||
Установите [Docker Desktop](https://www.docker.com/products/docker-desktop/), затем:
|
||||
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Сборка из исходников
|
||||
|
||||
```bash
|
||||
# Требования: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# Prerequisites: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # бэкенд
|
||||
cd web && npm install && npm run dev # фронтенд (новый терминал)
|
||||
go build -o nofx && ./nofx
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
### Обновление
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Настройка
|
||||
|
||||
**Режим для новичков**: пошаговый onboarding проводит через доступ к моделям, подключение биржи, настройку стратегии и первый запуск.
|
||||
|
||||
**Продвинутый режим**:
|
||||
|
||||
1. Настройте доступ к AI-моделям
|
||||
2. Подключите учетные данные биржи
|
||||
3. Создайте или импортируйте стратегию
|
||||
4. Создайте профиль AI-трейдера
|
||||
5. Запустите, мониторьте и улучшайте через дашборд
|
||||
|
||||
Все настройки доступны в Web UI по адресу **http://127.0.0.1:3000**.
|
||||
|
||||
---
|
||||
|
||||
## Развёртывание на сервере
|
||||
|
||||
**HTTP-развёртывание:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# Доступ через http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**HTTPS через Cloudflare:**
|
||||
|
||||
1. Добавьте домен в [Cloudflare](https://dash.cloudflare.com) (бесплатный план)
|
||||
2. A-запись → IP вашего сервера (Proxied)
|
||||
3. SSL/TLS → Flexible
|
||||
4. Установите `TRANSPORT_ENCRYPTION=true` в `.env`
|
||||
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Документация
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| [Архитектура](../../architecture/README.md) | Дизайн системы и индекс модулей |
|
||||
| [Модуль стратегий](../../architecture/STRATEGY_MODULE.md) | Выбор инструментов, AI-промпты, исполнение |
|
||||
| [FAQ](../../faq/README.md) | Частые вопросы |
|
||||
| [Начало работы](../../getting-started/README.md) | Гайд по деплою |
|
||||
|
||||
---
|
||||
|
||||
## Участие
|
||||
|
||||
См. [Contributing Guide](../../../CONTRIBUTING.md), [Code of Conduct](../../../CODE_OF_CONDUCT.md) и [Security Policy](../../../SECURITY.md).
|
||||
|
||||
### Программа для контрибьюторов
|
||||
|
||||
NOFX отслеживает значимые вклады и планирует вознаграждать контрибьюторов по мере роста экосистемы. Приоритетные issues имеют более высокий вес награды.
|
||||
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## Ссылки
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| Сайт | [nofxai.com](https://nofxai.com) |
|
||||
| Панель | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API Документация | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| :--- | :--- |
|
||||
| Website | [vergex.trade](https://vergex.trade) |
|
||||
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
|
||||
|
||||
> **Предупреждение**: AI автоторговля несёт значительные риски. Рекомендуется только для обучения/исследований или тестирования малых сумм.
|
||||
> **Предупреждение о рисках**: автоматическая торговля связана со значительным риском. Контролируйте размер позиции, понимайте устройство каждой площадки и не торгуйте средствами, потерю которых не можете себе позволить.
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
## Спонсоры
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
[Стать спонсором](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<p align="center"><strong>За підтримки <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
||||
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Ваш персональний AI торговий асистент.</strong><br/>
|
||||
<strong>Будь-який ринок. Будь-яка модель. Оплата USDC, без API ключів.</strong>
|
||||
<strong>AI-термінал для глобальних ринків.</strong><br/>
|
||||
<strong>Дослідження, генерація стратегій, виконання та моніторинг для акцій США, сировинних товарів, FX і криптоактивів.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,8 +17,6 @@
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,96 +31,131 @@
|
||||
|
||||
---
|
||||
|
||||
NOFX — це **автономний** AI торговий асистент з відкритим кодом. На відміну від традиційних AI інструментів, де потрібно вручну налаштовувати моделі, керувати API ключами та підключати джерела даних — AI у NOFX **сам аналізує ринки, обирає моделі та отримує дані**. Нульове втручання людини. Ви задаєте стратегію, AI робить все інше.
|
||||
NOFX — open-source AI-термінал для активних трейдерів, яким потрібен єдиний робочий простір для аналізу ринків, розробки стратегій, виконання угод і моніторингу портфеля.
|
||||
|
||||
**Повна автономність**: AI сам вирішує, яку модель використовувати, які ринкові дані отримати, коли торгувати. Без ручного налаштування моделей. Без жонглювання API ключами різних сервісів. Просто поповніть USDC гаманець і запустіть.
|
||||
|
||||
Ключова відмінність: **вбудовані [x402](https://x402.org) мікроплатежі**. Без API ключів. Поповніть USDC гаманець і платіть за кожен запит. Гаманець — це ваша ідентифікація.
|
||||
Продукт орієнтований на ліквідні глобальні ринки: акції США, товарні контракти, валютні пари й цифрові активи. AI-шар перетворює торговий намір на watchlists, сигнали, логіку стратегії, ризик-контроль і робочі процеси виконання.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Відкрийте **http://127.0.0.1:3000**. Готово.
|
||||
**http://127.0.0.1:3000** 을 엽니다.
|
||||
|
||||
---
|
||||
|
||||
## Як працює x402
|
||||
## Реєстрація на біржах
|
||||
|
||||
Традиційний процес: реєстрація → купівля кредитів → отримання API ключа → управління квотою → ротація ключів.
|
||||
Скористайтеся посиланнями нижче, щоб відкрити торгові акаунти для крипторинків і підтримуваних деривативів на акції США, FX та сировинні товари. Ці маршрути належать до партнерських програм NOFX і можуть містити знижки на комісії або реферальні переваги.
|
||||
|
||||
x402 процес:
|
||||
| Біржа | Статус | Реєстрація зі знижкою |
|
||||
| :--- | :---: | :--- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Реєстрація](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Реєстрація](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Реєстрація](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Реєстрація](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Реєстрація](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Реєстрація](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Реєстрація](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Реєстрація](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
```
|
||||
Запит → 402 (ось ціна) → гаманець підписує USDC → повтор → готово
|
||||
```
|
||||
---
|
||||
|
||||
Без акаунтів. Без API ключів. Без передоплати. Один гаманець, усі моделі.
|
||||
## Коротка демонстрація
|
||||
|
||||
### Вбудовані x402 провайдери
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
| Провайдер | Мережа | Моделі |
|
||||
|:---------|:------|:-------|
|
||||
| <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+ моделей |
|
||||
<p align="center">
|
||||
Натисніть на обкладинку, щоб переглянути демо.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Ринки
|
||||
|
||||
**Акції США · Сировинні товари · FX · Криптоактиви**
|
||||
|
||||
NOFX организует исследования, построение стратегий, исполнение и мониторинг вокруг мультиактивных рабочих процессов, а не вокруг одного торгового экрана.
|
||||
|
||||
---
|
||||
|
||||
## Доступ до AI-моделей
|
||||
|
||||
NOFX автоматично маршрутизує AI inference через [Claw402](https://claw402.ai). Користувачам не потрібно налаштовувати провайдерів моделей, керувати API-ключами або підтримувати окремі AI-акаунти. Термінал звертається до підтримуваних моделей на вимогу через pay-as-you-go інфраструктуру Claw402 та офіційний канал зі знижкою.
|
||||
|
||||
| Провайдер | Доступ |
|
||||
| :--- | :--- |
|
||||
| **Claw402** | [Доступ до AI-моделей pay-as-you-go з офіційною знижкою](https://claw402.ai) |
|
||||
|
||||
---
|
||||
|
||||
## Можливості
|
||||
|
||||
| Функція | Опис |
|
||||
|:--------|:------------|
|
||||
| **Мульти-AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — перемикання будь-коли |
|
||||
| **Мульти-біржа** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Студія стратегій** | Візуальний конструктор — джерела монет, індикатори, контроль ризиків |
|
||||
| **AI Арена дебатів** | Кілька AI обговорюють угоди, голосують, виконують |
|
||||
| **AI Змагання** | AI змагаються в реальному часі, рейтинг у таблиці лідерів |
|
||||
| **Telegram Агент** | Чат з торговим асистентом — стрімінг, виклик інструментів, пам'ять |
|
||||
| **Лабораторія бектесту** | Історична симуляція з кривою капіталу та метриками |
|
||||
| **Панель управління** | Позиції в реальному часі, P/L, логи AI рішень з Chain of Thought |
|
||||
|
||||
### Ринки
|
||||
|
||||
Криптовалюта · Акції США · Форекс · Метали
|
||||
|
||||
### Біржі (CEX)
|
||||
|
||||
| Біржа | Статус | Реєстрація (знижка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Реєстрація](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Реєстрація](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Реєстрація](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Реєстрація](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Реєстрація](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### Біржі (Perp-DEX)
|
||||
|
||||
| Біржа | Статус | Реєстрація (знижка) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Реєстрація](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [Реєстрація](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Реєстрація](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI Моделі (Режим API ключів)
|
||||
|
||||
| AI Модель | Статус | Отримати API ключ |
|
||||
|:---------|:------:|:------------|
|
||||
| <img src="../../../web/public/icons/deepseek.svg" width="20" height="20" style="vertical-align: middle;"/> **DeepSeek** | ✅ | [Отримати](https://platform.deepseek.com) |
|
||||
| <img src="../../../web/public/icons/qwen.svg" width="20" height="20" style="vertical-align: middle;"/> **Qwen** | ✅ | [Отримати](https://dashscope.console.aliyun.com) |
|
||||
| <img src="../../../web/public/icons/openai.svg" width="20" height="20" style="vertical-align: middle;"/> **OpenAI (GPT)** | ✅ | [Отримати](https://platform.openai.com) |
|
||||
| <img src="../../../web/public/icons/claude.svg" width="20" height="20" style="vertical-align: middle;"/> **Claude** | ✅ | [Отримати](https://console.anthropic.com) |
|
||||
| <img src="../../../web/public/icons/gemini.svg" width="20" height="20" style="vertical-align: middle;"/> **Gemini** | ✅ | [Отримати](https://aistudio.google.com) |
|
||||
| <img src="../../../web/public/icons/grok.svg" width="20" height="20" style="vertical-align: middle;"/> **Grok** | ✅ | [Отримати](https://console.x.ai) |
|
||||
| <img src="../../../web/public/icons/kimi.svg" width="20" height="20" style="vertical-align: middle;"/> **Kimi** | ✅ | [Отримати](https://platform.moonshot.cn) |
|
||||
| <img src="../../../web/public/icons/minimax.svg" width="20" height="20" style="vertical-align: middle;"/> **MiniMax** | ✅ | [Отримати](https://platform.minimaxi.com) |
|
||||
|
||||
### AI Моделі (Режим x402 — без API ключів)
|
||||
|
||||
15+ моделей через [Claw402](https://claw402.ai) — лише USDC гаманець
|
||||
| Возможность | Описание |
|
||||
| :--- | :--- |
|
||||
| **AI trading terminal** | Единое рабочее пространство для акций США, сырья, FX и криптоактивов |
|
||||
| **Доступ к AI-моделям** | Автоматический доступ к поддерживаемым провайдерам через Claw402 |
|
||||
| **Подключение бирж** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, Lighter |
|
||||
| **Strategy Studio** | Рыночные универсумы, индикаторы, риск-контроль и логика стратегий |
|
||||
| **Соревнование моделей** | Сравнение AI-трейдеров по live-результатам и таблице лидеров |
|
||||
| **Telegram Agent** | Управление и мониторинг ассистента через чат |
|
||||
| **Портфельный дашборд** | Позиции, P/L, история исполнения и логи решений модели |
|
||||
|
||||
---
|
||||
|
||||
## Встановлення
|
||||
## Скріншоти
|
||||
|
||||
<details>
|
||||
<summary><b>Сторінка налаштувань</b></summary>
|
||||
|
||||
| Конфігурація | Список трейдерів |
|
||||
| :----------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Дашборд</b></summary>
|
||||
|
||||
| Огляд | Графік ринку |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| Статистика торгів | Історія позицій |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| Позиції | Деталі трейдера |
|
||||
| :----------------------------------------------------------: | :---------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Strategy Studio</b></summary>
|
||||
|
||||
| Редактор стратегій | Налаштування індикаторів |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Змагання</b></summary>
|
||||
|
||||
| Режим змагання |
|
||||
| :-------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Установка
|
||||
|
||||
### Linux / macOS
|
||||
|
||||
@@ -128,7 +163,7 @@ x402 процес:
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (Хмара)
|
||||
### Railway (облако)
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
@@ -139,35 +174,155 @@ curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### З вихідного коду
|
||||
### Windows
|
||||
|
||||
Установите [Docker Desktop](https://www.docker.com/products/docker-desktop/), затем:
|
||||
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Сборка из исходников
|
||||
|
||||
```bash
|
||||
# Вимоги: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# Prerequisites: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # бекенд
|
||||
cd web && npm install && npm run dev # фронтенд (новий термінал)
|
||||
go build -o nofx && ./nofx
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
### Обновление
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Настройка
|
||||
|
||||
**Режим для новичков**: пошаговый onboarding проводит через доступ к моделям, подключение биржи, настройку стратегии и первый запуск.
|
||||
|
||||
**Продвинутый режим**:
|
||||
|
||||
1. Настройте доступ к AI-моделям
|
||||
2. Подключите учетные данные биржи
|
||||
3. Создайте или импортируйте стратегию
|
||||
4. Создайте профиль AI-трейдера
|
||||
5. Запустите, мониторьте и улучшайте через дашборд
|
||||
|
||||
Все настройки доступны в Web UI по адресу **http://127.0.0.1:3000**.
|
||||
|
||||
---
|
||||
|
||||
## Розгортання на сервері
|
||||
|
||||
**HTTP-розгортання:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# Доступ через http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**HTTPS через Cloudflare:**
|
||||
|
||||
1. Додайте домен у [Cloudflare](https://dash.cloudflare.com) (безкоштовний план)
|
||||
2. A-запис → IP вашого сервера (Proxied)
|
||||
3. SSL/TLS → Flexible
|
||||
4. Встановіть `TRANSPORT_ENCRYPTION=true` у `.env`
|
||||
|
||||
---
|
||||
|
||||
## Архітектура
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Документация
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| [Архитектура](../../architecture/README.md) | Дизайн системы и индекс модулей |
|
||||
| [Модуль стратегий](../../architecture/STRATEGY_MODULE.md) | Выбор инструментов, AI-промпты, исполнение |
|
||||
| [FAQ](../../faq/README.md) | Частые вопросы |
|
||||
| [Начало работы](../../getting-started/README.md) | Гайд по деплою |
|
||||
|
||||
---
|
||||
|
||||
## Участие
|
||||
|
||||
См. [Contributing Guide](../../../CONTRIBUTING.md), [Code of Conduct](../../../CODE_OF_CONDUCT.md) и [Security Policy](../../../SECURITY.md).
|
||||
|
||||
### Программа для контрибьюторов
|
||||
|
||||
NOFX отслеживает значимые вклады и планирует вознаграждать контрибьюторов по мере роста экосистемы. Приоритетные issues имеют более высокий вес награды.
|
||||
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## Посилання
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| Сайт | [nofxai.com](https://nofxai.com) |
|
||||
| Панель | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API Документація | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| :--- | :--- |
|
||||
| Website | [vergex.trade](https://vergex.trade) |
|
||||
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
|
||||
|
||||
> **Попередження**: AI автоторгівля несе значні ризики. Рекомендується лише для навчання/досліджень або тестування малих сум.
|
||||
> **Попередження про ризики**: автоматизована торгівля має значний ризик. Контролюйте розмір позиції, розумійте механіку кожного майданчика і не торгуйте коштами, втрату яких не можете собі дозволити.
|
||||
|
||||
---
|
||||
|
||||
## Ліцензія
|
||||
## Спонсори
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
[Стати спонсором](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<p align="center"><strong>Được hỗ trợ bởi <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
||||
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Trợ lý giao dịch AI cá nhân của bạn.</strong><br/>
|
||||
<strong>Mọi thị trường. Mọi mô hình. Thanh toán USDC, không cần API key.</strong>
|
||||
<strong>Thiết bị đầu cuối giao dịch AI cho thị trường toàn cầu.</strong><br/>
|
||||
<strong>Nghiên cứu, tạo chiến lược, thực thi và giám sát cho cổ phiếu Mỹ, hàng hóa, ngoại hối và crypto.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,8 +17,6 @@
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,90 +31,127 @@
|
||||
|
||||
---
|
||||
|
||||
NOFX là trợ lý giao dịch AI **tự chủ** mã nguồn mở. Không giống các công cụ AI truyền thống yêu cầu bạn cấu hình mô hình thủ công, quản lý API key và kết nối nguồn dữ liệu — AI của NOFX **tự nhận diện thị trường, tự chọn mô hình và tự lấy dữ liệu**. Không cần con người can thiệp. Bạn chỉ cần đặt chiến lược, AI xử lý mọi thứ còn lại.
|
||||
NOFX là thiết bị đầu cuối giao dịch AI mã nguồn mở cho các trader cần một không gian làm việc thống nhất để nghiên cứu thị trường, phát triển chiến lược, thực thi giao dịch và giám sát danh mục.
|
||||
|
||||
**Hoàn toàn tự chủ**: AI tự quyết định sử dụng mô hình nào, lấy dữ liệu thị trường gì, khi nào giao dịch. Không cần cấu hình mô hình thủ công. Không cần quản lý API key của nhiều dịch vụ. Chỉ cần nạp ví USDC và chạy.
|
||||
|
||||
Điểm khác biệt: **tích hợp thanh toán vi mô [x402](https://x402.org)**. Không cần API key. Nạp ví USDC và thanh toán theo yêu cầu. Ví chính là danh tính của bạn.
|
||||
Sản phẩm tập trung vào các thị trường thanh khoản toàn cầu: cổ phiếu Mỹ, hợp đồng hàng hóa, cặp FX và tài sản số. Lớp AI chuyển ý định giao dịch thành watchlist, tín hiệu, logic chiến lược, kiểm soát rủi ro và workflow thực thi.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Mở **http://127.0.0.1:3000**. Xong.
|
||||
**http://127.0.0.1:3000** 을 엽니다.
|
||||
|
||||
---
|
||||
|
||||
## x402 hoạt động như thế nào
|
||||
## Đăng ký sàn giao dịch
|
||||
|
||||
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.
|
||||
Sử dụng các liên kết bên dưới để mở tài khoản giao dịch cho crypto và các thị trường phái sinh cổ phiếu Mỹ, FX, hàng hóa được hỗ trợ. Các tuyến này thuộc chương trình đối tác NOFX và có thể bao gồm ưu đãi phí hoặc quyền lợi giới thiệu.
|
||||
|
||||
Quy trình x402:
|
||||
|
||||
```
|
||||
Yêu cầu → 402 (đây là giá) → ví ký USDC → thử lại → xong
|
||||
```
|
||||
|
||||
Không tài khoản. Không API key. Không trả trước. Một ví, tất cả mô hình.
|
||||
|
||||
### Nhà cung cấp x402 tích hợp
|
||||
|
||||
| Nhà cung cấp | Chain | Mô hình |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, Gemini, Kimi — 15+ mô hình |
|
||||
|
||||
---
|
||||
|
||||
## Tính năng
|
||||
|
||||
| Tính năng | Mô tả |
|
||||
|:--------|:------------|
|
||||
| **Đa AI** | DeepSeek, Qwen, GPT, Claude, Gemini, Grok, Kimi, MiniMax — chuyển đổi bất cứ lúc nào |
|
||||
| **Đa Sàn** | Binance, Bybit, OKX, Bitget, KuCoin, Gate, Hyperliquid, Aster, Lighter |
|
||||
| **Strategy Studio** | Trình xây dựng trực quan — nguồn coin, chỉ báo, kiểm soát rủi ro |
|
||||
| **AI Competition** | AI cạnh tranh thời gian thực, bảng xếp hạng hiệu suất |
|
||||
| **Telegram Agent** | Chat với trợ lý giao dịch — streaming, gọi công cụ, bộ nhớ |
|
||||
| **Dashboard** | Vị thế trực tiếp, P/L, nhật ký quyết định AI với Chain of Thought |
|
||||
|
||||
### Thị trường
|
||||
|
||||
Crypto · Cổ phiếu Mỹ · Forex · Kim loại
|
||||
|
||||
### Sàn giao dịch (CEX)
|
||||
|
||||
| Sàn | Trạng thái | Đăng ký (Giảm phí) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| Sàn | 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)
|
||||
---
|
||||
|
||||
| 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 nhanh
|
||||
|
||||
### Mô hình AI (Chế độ x402 — Không cần API Key)
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
15+ mô hình qua [Claw402](https://claw402.ai) — chỉ cần ví USDC
|
||||
<p align="center">
|
||||
Nhấp vào ảnh bìa để xem video demo.
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Thị trường
|
||||
|
||||
**Cổ phiếu Mỹ · Hàng hóa · Ngoại hối · Crypto**
|
||||
|
||||
NOFX tổ chức nghiên cứu, xây dựng chiến lược, thực thi và giám sát theo workflow đa tài sản thay vì một màn hình sàn đơn lẻ.
|
||||
|
||||
---
|
||||
|
||||
## Truy cập mô hình AI
|
||||
|
||||
NOFX tự động định tuyến AI inference qua [Claw402](https://claw402.ai). Người dùng không cần cấu hình nhà cung cấp mô hình, quản lý API key hoặc duy trì tài khoản AI riêng. Terminal truy cập các mô hình được hỗ trợ theo nhu cầu qua hạ tầng pay-as-you-go của Claw402 và định tuyến qua kênh ưu đãi chính thức.
|
||||
|
||||
| Nhà cung cấp | Truy cập |
|
||||
| :--- | :--- |
|
||||
| **Claw402** | [Truy cập mô hình AI pay-as-you-go với ưu đãi chính thức](https://claw402.ai) |
|
||||
|
||||
---
|
||||
|
||||
## Năng lực
|
||||
|
||||
| Năng lực | Mô tả |
|
||||
| :--- | :--- |
|
||||
| **AI trading terminal** | Không gian làm việc thống nhất cho cổ phiếu Mỹ, hàng hóa, ngoại hối và crypto |
|
||||
| **Truy cập mô hình AI** | Tự động kết nối nhà cung cấp được hỗ trợ qua Claw402 |
|
||||
| **Kết nối sàn** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, Lighter |
|
||||
| **Strategy Studio** | Universe thị trường, chỉ báo, kiểm soát rủi ro và logic chiến lược |
|
||||
| **Cạnh tranh mô hình** | So sánh AI trader bằng hiệu suất live và bảng xếp hạng |
|
||||
| **Telegram Agent** | Điều khiển và giám sát trợ lý giao dịch qua chat |
|
||||
| **Dashboard danh mục** | Vị thế, P/L, lịch sử thực thi và log quyết định của mô hình |
|
||||
|
||||
---
|
||||
|
||||
## Ảnh chụp màn hình
|
||||
|
||||
<details>
|
||||
<summary><b>Trang cấu hình</b></summary>
|
||||
|
||||
| Cấu hình | Danh sách trader |
|
||||
| :----------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Dashboard</b></summary>
|
||||
|
||||
| Tổng quan | Biểu đồ thị trường |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| Thống kê giao dịch | Lịch sử vị thế |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| Vị thế | Chi tiết trader |
|
||||
| :----------------------------------------------------------: | :---------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Strategy Studio</b></summary>
|
||||
|
||||
| Trình soạn chiến lược | Cấu hình chỉ báo |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Cạnh tranh</b></summary>
|
||||
|
||||
| Chế độ cạnh tranh |
|
||||
| :-------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -137,34 +174,154 @@ curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Từ mã nguồn
|
||||
### Windows
|
||||
|
||||
Cài [Docker Desktop](https://www.docker.com/products/docker-desktop/), sau đó:
|
||||
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Build từ source
|
||||
|
||||
```bash
|
||||
# Yêu cầu: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# Prerequisites: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend (terminal mới)
|
||||
go build -o nofx && ./nofx
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
### Cập nhật
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Thiết lập
|
||||
|
||||
**Chế độ người mới**: onboarding có hướng dẫn giúp người dùng mới hoàn tất truy cập mô hình, kết nối sàn, cấu hình chiến lược và lần triển khai đầu tiên.
|
||||
|
||||
**Chế độ nâng cao**:
|
||||
|
||||
1. Cấu hình truy cập mô hình AI
|
||||
2. Kết nối thông tin xác thực sàn
|
||||
3. Xây dựng hoặc import chiến lược
|
||||
4. Tạo hồ sơ AI trader
|
||||
5. Khởi chạy, giám sát và cải thiện từ dashboard
|
||||
|
||||
Tất cả cấu hình có trong Web UI tại **http://127.0.0.1:3000**.
|
||||
|
||||
---
|
||||
|
||||
## Triển khai lên máy chủ
|
||||
|
||||
**Triển khai HTTP:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# Truy cập qua http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**HTTPS qua Cloudflare:**
|
||||
|
||||
1. Thêm domain vào [Cloudflare](https://dash.cloudflare.com) (gói miễn phí)
|
||||
2. Bản ghi A → IP máy chủ của bạn (Proxied)
|
||||
3. SSL/TLS → Flexible
|
||||
4. Đặt `TRANSPORT_ENCRYPTION=true` trong `.env`
|
||||
|
||||
---
|
||||
|
||||
## Kiến trúc
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tài liệu
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| [Kiến trúc](../../architecture/README.md) | Thiết kế hệ thống và chỉ mục module |
|
||||
| [Module chiến lược](../../architecture/STRATEGY_MODULE.md) | Chọn công cụ, prompt AI, thực thi |
|
||||
| [FAQ](../../faq/README.md) | Câu hỏi thường gặp |
|
||||
| [Bắt đầu](../../getting-started/README.md) | Hướng dẫn triển khai |
|
||||
|
||||
---
|
||||
|
||||
## Đóng góp
|
||||
|
||||
Xem [Contributing Guide](../../../CONTRIBUTING.md), [Code of Conduct](../../../CODE_OF_CONDUCT.md), và [Security Policy](../../../SECURITY.md).
|
||||
|
||||
### Chương trình contributor
|
||||
|
||||
NOFX ghi nhận các đóng góp có ý nghĩa và dự định thưởng cho contributor khi hệ sinh thái phát triển. Issue ưu tiên có trọng số thưởng cao hơn.
|
||||
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## Liên kết
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| Website | [nofxai.com](https://nofxai.com) |
|
||||
| Dashboard | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API Docs | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| :--- | :--- |
|
||||
| Website | [vergex.trade](https://vergex.trade) |
|
||||
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
|
||||
|
||||
> **Cảnh báo rủi ro**: Giao dịch tự động AI có rủi ro đáng kể. Chỉ nên sử dụng cho mục đích học tập/nghiên cứu hoặc số tiền nhỏ.
|
||||
> **Cảnh báo rủi ro**: Giao dịch tự động có rủi ro đáng kể. Hãy dùng kích thước vị thế phù hợp, hiểu từng venue và không giao dịch số vốn bạn không thể mất.
|
||||
|
||||
---
|
||||
|
||||
## Nhà tài trợ
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
[Trở thành nhà tài trợ](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<p align="center"><strong>由 <a href="https://vergex.trade">vergex.trade</a> 支持</strong></p>
|
||||
|
||||
<h1 align="center">NOFX</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>你的个人 AI 交易助手。</strong><br/>
|
||||
<strong>任何市场。任何模型。用 USDC 付费,无需 API Key。</strong>
|
||||
<strong>面向全球市场的 AI 交易终端。</strong><br/>
|
||||
<strong>覆盖美股、大宗商品、外汇与加密市场的研究、策略生成、执行与监控。</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -15,8 +17,6 @@
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://x402.org"><img src="https://img.shields.io/badge/x402-USDC%20Payments-2775CA?style=flat" alt="x402"></a>
|
||||
<a href="https://claw402.ai"><img src="https://img.shields.io/badge/Claw402-AI%20Gateway-FF6B35?style=flat" alt="Claw402"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -33,91 +33,127 @@
|
||||
|
||||
---
|
||||
|
||||
NOFX 是一个开源的**自主式** AI 交易助手。与需要手动配置模型、管理 API Key、接入数据源的传统 AI 工具不同 —— NOFX 的 AI **自主感知市场、自选模型、自动获取数据**。零人工干预。你只需设定策略,AI 负责一切。
|
||||
NOFX 是一个开源 AI 交易终端,面向需要统一工作区完成市场研究、策略开发、交易执行与组合监控的活跃交易者。
|
||||
|
||||
**完全自主**:AI 自行决定使用哪个模型、获取什么市场数据、何时交易。无需手动配置模型,无需管理各种服务的 API Key。只需充值 USDC 钱包,一键启动。
|
||||
|
||||
核心差异:**内置 [x402](https://x402.org) 微支付协议**。无需 API Key,充值 USDC 钱包即可按需付费。钱包就是你的身份。
|
||||
产品围绕全球高流动性市场设计:美股、大宗商品合约、外汇货币对与数字资产。AI 层将交易意图转化为观察列表、信号、策略逻辑、风控约束与执行工作流。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
打开 **http://127.0.0.1:3000**,完成。
|
||||
打开 **http://127.0.0.1:3000**。
|
||||
|
||||
---
|
||||
|
||||
## x402 如何工作
|
||||
## 注册交易所
|
||||
|
||||
传统流程:注册账号 → 购买额度 → 获取 API Key → 管理配额 → 轮换密钥。
|
||||
通过以下链接开通交易账户,可交易加密资产以及平台支持的美股、外汇和大宗商品衍生品市场。这些链接来自 NOFX 合作伙伴计划,可能包含手续费折扣或推荐权益。
|
||||
|
||||
x402 流程:
|
||||
|
||||
```
|
||||
请求 → 402(返回价格)→ 钱包签名 USDC → 重试 → 完成
|
||||
```
|
||||
|
||||
无需注册。无需 API Key。无需预付费。一个钱包,所有模型。
|
||||
|
||||
### 内置 x402 提供商
|
||||
|
||||
| 提供商 | 链 | 模型 |
|
||||
|:---------|:------|:-------|
|
||||
| <img src="../../../web/public/icons/claw402.png" width="20" height="20" style="vertical-align: middle;"/> **[Claw402](https://claw402.ai)** | Base | GPT-5.4、Claude Opus、DeepSeek、Qwen、Grok、Gemini、Kimi — 15+ 模型 |
|
||||
|
||||
---
|
||||
|
||||
## 功能概览
|
||||
|
||||
| 功能 | 描述 |
|
||||
|:--------|:------------|
|
||||
| **多 AI** | DeepSeek、Qwen、GPT、Claude、Gemini、Grok、Kimi、MiniMax — 随时切换 |
|
||||
| **多交易所** | Binance、Bybit、OKX、Bitget、KuCoin、Gate、Hyperliquid、Aster、Lighter |
|
||||
| **策略工作室** | 可视化构建器 — 币种来源、指标、风控 |
|
||||
| **AI 竞赛** | AI 实时竞争,排行榜排名 |
|
||||
| **Telegram Agent** | 与交易助手对话 — 流式输出、工具调用、记忆 |
|
||||
| **回测实验室** | 历史模拟,权益曲线和性能指标 |
|
||||
| **仪表板** | 实时持仓、盈亏、AI 决策日志与思维链 |
|
||||
|
||||
### 市场
|
||||
|
||||
加密货币 · 美股 · 外汇 · 贵金属
|
||||
|
||||
### 交易所 (CEX)
|
||||
|
||||
| 交易所 | 状态 | 注册 (手续费折扣) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| 交易所 | 状态 | 享手续费折扣注册 |
|
||||
| :--- | :---: | :--- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [注册](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [注册](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [注册](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [注册](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [注册](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [注册](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
|
||||
### 交易所 (Perp-DEX)
|
||||
|
||||
| 交易所 | 状态 | 注册 (手续费折扣) |
|
||||
|:---------|:------:|:------------------------|
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [注册](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster DEX** | ✅ | [注册](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [注册](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [注册](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
### AI 模型 (API Key 模式)
|
||||
---
|
||||
|
||||
| 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)
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
15+ 模型通过 [Claw402](https://claw402.ai) 接入 — 只需一个 USDC 钱包
|
||||
<p align="center">
|
||||
点击封面图观看演示视频。
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 市场
|
||||
|
||||
**美股 · 大宗商品 · 外汇 · 加密资产**
|
||||
|
||||
NOFX 按多资产工作流组织研究、策略构建、执行与监控,而不是停留在单一交易所界面。
|
||||
|
||||
---
|
||||
|
||||
## AI 模型接入
|
||||
|
||||
NOFX 自动通过 [Claw402](https://claw402.ai) 路由 AI 推理请求。用户无需配置大模型供应商、管理 API Key 或维护独立 AI 账户。终端按需按次调用 Claw402 的 AI 模型基础设施,并通过官方折扣通道完成路由。
|
||||
|
||||
| 提供商 | 接入 |
|
||||
| :--- | :--- |
|
||||
| **Claw402** | [通过官方折扣通道按需使用 AI 模型](https://claw402.ai) |
|
||||
|
||||
---
|
||||
|
||||
## 能力
|
||||
|
||||
| 能力 | 描述 |
|
||||
| :--- | :--- |
|
||||
| **AI 交易终端** | 面向美股、大宗商品、外汇与加密资产的一体化工作区 |
|
||||
| **AI 模型接入** | 通过 Claw402 自动接入支持的模型供应商 |
|
||||
| **交易所连接** | Binance、Bybit、OKX、Hyperliquid、Bitget、KuCoin、Gate、Aster、Lighter |
|
||||
| **策略工作室** | 市场范围、指标、风控与策略逻辑 |
|
||||
| **模型竞赛** | 比较 AI 交易员的实时表现与排行榜 |
|
||||
| **Telegram Agent** | 通过聊天控制和监控交易助手 |
|
||||
| **组合仪表板** | 持仓、盈亏、执行历史与模型决策日志 |
|
||||
|
||||
---
|
||||
|
||||
## 截图
|
||||
|
||||
<details>
|
||||
<summary><b>配置页</b></summary>
|
||||
|
||||
| 配置 | 交易员列表 |
|
||||
| :----------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>仪表板</b></summary>
|
||||
|
||||
| 概览 | 行情图表 |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| 交易统计 | 持仓历史 |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| 持仓 | 交易员详情 |
|
||||
| :----------------------------------------------------------: | :---------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-positions.png" width="400"/> | <img src="../../../screenshots/details-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>策略工作室</b></summary>
|
||||
|
||||
| 策略编辑器 | 指标配置 |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/strategy-studio.png" width="400"/> | <img src="../../../screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>竞赛</b></summary>
|
||||
|
||||
| 竞赛模式 |
|
||||
| :-------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -129,7 +165,7 @@ x402 流程:
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
### Railway (云部署)
|
||||
### Railway(云部署)
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
@@ -152,13 +188,13 @@ docker compose -f docker-compose.prod.yml up -d
|
||||
### 从源码构建
|
||||
|
||||
```bash
|
||||
# 前置条件: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# Prerequisites: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # 后端
|
||||
cd web && npm install && npm run dev # 前端 (新终端)
|
||||
go build -o nofx && ./nofx
|
||||
cd web && npm install && npm run dev
|
||||
```
|
||||
|
||||
### 更新
|
||||
@@ -171,24 +207,68 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
|
||||
## 配置
|
||||
|
||||
**新手模式**:首次使用的用户可以在注册时选择新手模式,系统会引导你逐步完成 AI、交易所和策略的配置。
|
||||
**新手模式**:引导式 onboarding 帮助新用户完成模型访问、交易所连接、策略配置与首次部署。
|
||||
|
||||
**进阶模式**:
|
||||
|
||||
1. **AI** — 添加 API Key 或配置 x402 钱包
|
||||
2. **交易所** — 连接交易所 API 凭证
|
||||
3. **策略** — 在策略工作室构建
|
||||
4. **交易员** — 组合 AI + 交易所 + 策略
|
||||
5. **交易** — 从仪表板启动
|
||||
1. 配置 AI 模型访问
|
||||
2. 连接交易所凭证
|
||||
3. 构建或导入策略
|
||||
4. 创建 AI 交易员配置
|
||||
5. 在仪表板启动、监控并迭代
|
||||
|
||||
所有操作通过 Web 界面完成:**http://127.0.0.1:3000**
|
||||
所有配置均可在 Web UI **http://127.0.0.1:3000** 完成。
|
||||
|
||||
---
|
||||
|
||||
## 部署到服务器
|
||||
|
||||
**HTTP 部署:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# 通过 http://YOUR_IP:3000 访问
|
||||
```
|
||||
|
||||
**通过 Cloudflare 启用 HTTPS:**
|
||||
|
||||
1. 在 [Cloudflare](https://dash.cloudflare.com)(免费套餐)添加域名
|
||||
2. A 记录指向你的服务器 IP(开启代理)
|
||||
3. SSL/TLS 选择 Flexible
|
||||
4. 在 `.env` 中设置 `TRANSPORT_ENCRYPTION=true`
|
||||
|
||||
---
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
│ KuCoin · Gate · Aster · Lighter │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| :--- | :--- |
|
||||
| [架构概览](../../architecture/README.md) | 系统设计和模块索引 |
|
||||
| [策略模块](../../architecture/STRATEGY_MODULE.md) | 币种选择、AI 提示词、执行 |
|
||||
| [常见问题](../../faq/README.md) | FAQ |
|
||||
@@ -198,39 +278,52 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
|
||||
## 贡献
|
||||
|
||||
查看 [贡献指南](../../../CONTRIBUTING.md) · [行为准则](../../../CODE_OF_CONDUCT.md) · [安全政策](../../../SECURITY.md)
|
||||
查看 [贡献指南](../../../CONTRIBUTING.md)、[行为准则](../../../CODE_OF_CONDUCT.md) 与 [安全政策](../../../SECURITY.md)。
|
||||
|
||||
### 贡献者空投计划
|
||||
### 贡献者计划
|
||||
|
||||
所有贡献在 GitHub 上追踪。当 NOFX 产生收入时,贡献者将获得空投。
|
||||
NOFX 会记录有价值的贡献,并计划在生态增长后回馈贡献者。优先级 Issue 拥有更高奖励权重。
|
||||
|
||||
**解决 [置顶 Issue](https://github.com/NoFxAiOS/nofx/issues) 的 PR 获得最高奖励!**
|
||||
|
||||
| 贡献类型 | 权重 |
|
||||
|:-------------|:------:|
|
||||
| 置顶 Issue PR | ★★★★★★ |
|
||||
| 代码提交 (合并的 PR) | ★★★★★ |
|
||||
| Bug 修复 | ★★★★ |
|
||||
| 功能建议 | ★★★ |
|
||||
| Bug 报告 | ★★ |
|
||||
| 文档 | ★★ |
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## 链接
|
||||
|
||||
| | |
|
||||
|:--|:--|
|
||||
| 官网 | [nofxai.com](https://nofxai.com) |
|
||||
| 数据面板 | [nofxos.ai/dashboard](https://nofxos.ai/dashboard) |
|
||||
| API 文档 | [nofxos.ai/api-docs](https://nofxos.ai/api-docs) |
|
||||
| :--- | :--- |
|
||||
| 官网 | [vergex.trade](https://vergex.trade) |
|
||||
| Dashboard | [vergex.trade/explore](https://vergex.trade/explore) |
|
||||
| Telegram | [nofx_dev_community](https://t.me/nofx_dev_community) |
|
||||
| Twitter | [@nofx_official](https://x.com/nofx_official) |
|
||||
| Twitter | [@vergex_ai](https://x.com/vergex_ai) |
|
||||
|
||||
> **风险提示**: AI 自动交易存在重大风险。建议仅用于学习/研究或小额测试。
|
||||
> **风险提示**:自动化交易存在重大风险。请控制仓位,理解每个交易场所的机制,不要投入无法承受损失的资金。
|
||||
|
||||
---
|
||||
|
||||
## 赞助者
|
||||
|
||||
<a href="https://github.com/pjl914335852-ux"><img src="https://github.com/pjl914335852-ux.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cat9999aaa"><img src="https://github.com/cat9999aaa.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/1733055465"><img src="https://github.com/1733055465.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/kolal2020"><img src="https://github.com/kolal2020.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/CyberFFarm"><img src="https://github.com/CyberFFarm.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/vip3001003"><img src="https://github.com/vip3001003.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/mrtluh"><img src="https://github.com/mrtluh.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/cpcp1117-source"><img src="https://github.com/cpcp1117-source.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/match-007"><img src="https://github.com/match-007.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/leiwuhen1715"><img src="https://github.com/leiwuhen1715.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
<a href="https://github.com/SHAOXIA1991"><img src="https://github.com/SHAOXIA1991.png" width="50" height="50" style="border-radius:50%"/></a>
|
||||
|
||||
[成为赞助者](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
91
go.mod
91
go.mod
@@ -1,129 +1,104 @@
|
||||
module nofx
|
||||
|
||||
go 1.25.3
|
||||
go 1.25.11
|
||||
|
||||
require (
|
||||
github.com/adshao/go-binance/v2 v2.8.9
|
||||
github.com/agiledragon/gomonkey/v2 v2.13.0
|
||||
github.com/ethereum/go-ethereum v1.16.7
|
||||
github.com/antihax/optional v1.0.0
|
||||
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6
|
||||
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48
|
||||
github.com/ethereum/go-ethereum v1.17.3
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/sonirico/go-hyperliquid v0.26.0
|
||||
github.com/sonirico/go-hyperliquid v0.36.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.42.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/text v0.37.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
modernc.org/sqlite v1.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.0.0-rc.1 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect
|
||||
github.com/antihax/optional v1.0.0 // indirect
|
||||
github.com/armon/go-radix v1.0.0 // indirect
|
||||
github.com/bitly/go-simplejson v0.5.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.0 // indirect
|
||||
github.com/blendle/zapdriver v1.3.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/consensys/gnark-crypto v0.19.0 // indirect
|
||||
github.com/crate-crypto/go-eth-kzg v1.4.0 // indirect
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect
|
||||
github.com/consensys/gnark-crypto v0.19.2 // indirect
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/elastic/go-sysinfo v1.15.4 // indirect
|
||||
github.com/elastic/go-windows v1.0.2 // indirect
|
||||
github.com/elliottech/lighter-go v0.0.0-20251104171447-78b9b55ebc48 // indirect
|
||||
github.com/elliottech/poseidon_crypto v0.0.11 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5 // indirect
|
||||
github.com/ethereum/go-verkle v0.2.2 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gagliardetto/binary v0.8.0 // indirect
|
||||
github.com/gagliardetto/solana-go v1.14.0 // indirect
|
||||
github.com/gagliardetto/treeout v0.1.4 // indirect
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.4 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/holiman/uint256 v1.3.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/jpillora/backoff v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.16.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mailru/easyjson v0.9.2 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/procfs v0.17.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sonirico/vago v0.10.0 // indirect
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd // indirect
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 // indirect
|
||||
github.com/sonirico/vago v0.11.4 // indirect
|
||||
github.com/sonirico/vago/lol v0.1.0 // indirect
|
||||
github.com/supranational/blst v0.3.16 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.7 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 // indirect
|
||||
go.elastic.co/apm/v2 v2.7.1 // indirect
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.2 // indirect
|
||||
go.elastic.co/apm/v2 v2.7.2 // indirect
|
||||
go.elastic.co/fastjson v1.5.1 // indirect
|
||||
go.mongodb.org/mongo-driver v1.12.2 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
go.uber.org/ratelimit v0.2.0 // indirect
|
||||
go.uber.org/zap v1.21.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/mod v0.27.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/term v0.35.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/postgres v1.6.0 // indirect
|
||||
gorm.io/driver/sqlite v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
howett.net/plist v1.0.1 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
247
go.sum
247
go.sum
@@ -1,34 +1,19 @@
|
||||
filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmGfU=
|
||||
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
|
||||
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
|
||||
github.com/adshao/go-binance/v2 v2.8.7 h1:n7jkhwIHMdtd/9ZU2gTqFV15XVSbUCjyFlOUAtTd8uU=
|
||||
github.com/adshao/go-binance/v2 v2.8.7/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM=
|
||||
github.com/adshao/go-binance/v2 v2.8.9 h1:NX+4u/LgEmrjTS7OMWU+9ZgfHKFM61RPhnr9/SqWPhc=
|
||||
github.com/adshao/go-binance/v2 v2.8.9/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM=
|
||||
github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo=
|
||||
github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 h1:MzBOUgng9orim59UnfUTLRjMpd09C5uEVQ6RPGeCaVI=
|
||||
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129/go.mod h1:rFgpPQZYZ8vdbc+48xibu8ALc3yeyd64IhHS+PU6Yyg=
|
||||
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
|
||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
|
||||
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y=
|
||||
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
||||
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
|
||||
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
|
||||
github.com/bits-and-blooms/bitset v1.24.0 h1:H4x4TuulnokZKvHLfzVRTHJfFfnHEeSYJizujEZvmAM=
|
||||
github.com/bits-and-blooms/bitset v1.24.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/blendle/zapdriver v1.3.1 h1:C3dydBOWYRiOk+B8X9IVZ5IOe+7cl+tGOexN4QqHfpE=
|
||||
github.com/blendle/zapdriver v1.3.1/go.mod h1:mdXfREi6u5MArG4j9fewC+FGnXaBR+T4Ox4J2u4eHCc=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6 h1:41FLQtKmxWEdyjdgrAm9lZFdS0Ax2XsDxkd/fuztsyQ=
|
||||
github.com/bybit-exchange/bybit.go.api v0.0.0-20250727214011-c9347d6804d6/go.mod h1:P22TFRynmYRrquJCPalKxZgIIIc9+PkC4kQPeejitsI=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
@@ -37,15 +22,12 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/consensys/gnark-crypto v0.19.0 h1:zXCqeY2txSaMl6G5wFpZzMWJU9HPNh8qxPnYJ1BL9vA=
|
||||
github.com/consensys/gnark-crypto v0.19.0/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
|
||||
github.com/consensys/gnark-crypto v0.19.2 h1:qrEAIXq3T4egxqiliFFoNrepkIWVEeIYwt3UL0fvS80=
|
||||
github.com/consensys/gnark-crypto v0.19.2/go.mod h1:rT23F0XSZqE0mUA0+pRtnL56IbPxs6gp4CeRsBk4XS0=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/crate-crypto/go-eth-kzg v1.4.0 h1:WzDGjHk4gFg6YzV0rJOAsTK4z3Qkz5jd4RE3DAvPFkg=
|
||||
github.com/crate-crypto/go-eth-kzg v1.4.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg=
|
||||
github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM=
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc=
|
||||
github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -65,26 +47,14 @@ github.com/elliottech/poseidon_crypto v0.0.11 h1:iX4rCg0m1XIX/7mhXVUEYUJIdQD57zN
|
||||
github.com/elliottech/poseidon_crypto v0.0.11/go.mod h1:NhWxSjPGr5JXRuB2Aepl/+ZrbmUG3hvku/GarB1JR8c=
|
||||
github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A=
|
||||
github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5 h1:aVtoLK5xwJ6c5RiqO8g8ptJ5KU+2Hdquf6G3aXiHh5s=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.5/go.mod h1:u59hRTTah4Co6i9fDWtiCjTrblJv0UwsqZKCc0GfgUs=
|
||||
github.com/ethereum/go-ethereum v1.16.5 h1:GZI995PZkzP7ySCxEFaOPzS8+bd8NldE//1qvQDQpe0=
|
||||
github.com/ethereum/go-ethereum v1.16.5/go.mod h1:kId9vOtlYg3PZk9VwKbGlQmSACB5ESPTBGT+M9zjmok=
|
||||
github.com/ethereum/go-ethereum v1.16.7 h1:qeM4TvbrWK0UC0tgkZ7NiRsmBGwsjqc64BHo20U59UQ=
|
||||
github.com/ethereum/go-ethereum v1.16.7/go.mod h1:Fs6QebQbavneQTYcA39PEKv2+zIjX7rPUZ14DER46wk=
|
||||
github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8=
|
||||
github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls=
|
||||
github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw=
|
||||
github.com/ethereum/go-ethereum v1.17.3 h1:Ev/sQHH+UdKZHWjuVzhu2pxhi/sXaPZl23Q+Q5LDd4Q=
|
||||
github.com/ethereum/go-ethereum v1.17.3/go.mod h1:f2EhRwqewIZkGoQekywI2Y2RZAMTSavLNkD9qItFy1A=
|
||||
github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY=
|
||||
github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gagliardetto/binary v0.8.0 h1:U9ahc45v9HW0d15LoN++vIXSJyqR/pWw8DDlhd7zvxg=
|
||||
github.com/gagliardetto/binary v0.8.0/go.mod h1:2tfj51g5o9dnvsc+fL3Jxr22MuWzYXwx9wEoN0XQ7/c=
|
||||
github.com/gagliardetto/solana-go v1.14.0 h1:3WfAi70jOOjAJ0deFMjdhFYlLXATF4tOQXsDNWJtOLw=
|
||||
github.com/gagliardetto/solana-go v1.14.0/go.mod h1:l/qqqIN6qJJPtxW/G1PF4JtcE3Zg2vD2EliZrr9Gn5k=
|
||||
github.com/gagliardetto/treeout v0.1.4 h1:ozeYerrLCmCubo1TcIjFiOWTTGteOOHND1twdFpgwaw=
|
||||
github.com/gagliardetto/treeout v0.1.4/go.mod h1:loUefvXTrlRG5rYmJmExNryyBRh8f89VZhmMOyCyqok=
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3 h1:JQ2+s1pG4bL+JeLQyGy9c7YLr7hxRI8g7vkAuQYl75k=
|
||||
github.com/gateio/gateapi-go/v6 v6.104.3/go.mod h1:racCcjrdyOUbRDO5eCUGUiyDPrF/ZmwBj/bupPZTVLY=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -110,14 +80,12 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -136,8 +104,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
@@ -154,18 +122,10 @@ github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
|
||||
github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
@@ -176,10 +136,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
@@ -187,50 +145,34 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
|
||||
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
|
||||
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
|
||||
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1 h1:mPMvm6X6tf4w8y7j9YIt6V9jfWhL6QlbEc7CCmeQlWk=
|
||||
github.com/mostynb/zstdpool-freelist v0.0.0-20201229113212-927304c0c3b1/go.mod h1:ye2e/VUEtE2BHE+G/QcKkcLQVAEJoYRFj5VUOQatCRE=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
|
||||
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
@@ -238,32 +180,24 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU=
|
||||
github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/sonirico/go-hyperliquid v0.17.0 h1:eXYACWupwu41O1VtKw17dqe9oOLQ1A2nRElGhg5Ox+4=
|
||||
github.com/sonirico/go-hyperliquid v0.17.0/go.mod h1:sH51Vsu+tPUwc95TL2MoQ8YXSewLWBEJirgzo7sZx6w=
|
||||
github.com/sonirico/go-hyperliquid v0.26.0 h1:C2KjaD2R/AxH1FOPl6W1LyvAx/XUHdTQYgjb4PUcPN0=
|
||||
github.com/sonirico/go-hyperliquid v0.26.0/go.mod h1:SYzazq5hqC8lI1+MgSO0aJVrf0TAfyibp5NjUqnwv2I=
|
||||
github.com/sonirico/vago v0.9.0 h1:DF2OWW2Aaf1xPZmnFv79kBrHmjKX3mVvMbP08vERlKo=
|
||||
github.com/sonirico/vago v0.9.0/go.mod h1:fZxV1RzMe2eaZokbbDvuyoOzG3YapzqRQoOiD9VyJH0=
|
||||
github.com/sonirico/vago v0.10.0 h1:y+4Wo56tK+88a5lUwVrZUO2RRLaPcBgjI5cupKpT1Oc=
|
||||
github.com/sonirico/vago v0.10.0/go.mod h1:HCfnyPHId7V+zBZ5BLfIsdHIO+ewo6+uhF1N0hxlldc=
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd h1:rbvNORW8/0AtH/8W/SUwUykbuh2SeQBrNgFLqYpGTWY=
|
||||
github.com/sonirico/vago/lol v0.0.0-20250901170347-2d1d82c510bd/go.mod h1:pteYccB32seEf19i0TPk7DKdEZdWJ/n9K9DF8AFeXGU=
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091 h1:RN5mrigyirb8anBEtdjtHFIufXdacyTi6i4KBfeNXeo=
|
||||
github.com/streamingfast/logging v0.0.0-20230608130331-f22c91403091/go.mod h1:VlduQ80JcGJSargkRU4Sg9Xo63wZD/l8A5NC/Uo1/uU=
|
||||
github.com/sonirico/go-hyperliquid v0.36.0 h1:97aNCFf7PbvXtpCh+kdpqlAmLCyu4vB/USaKL73mSB8=
|
||||
github.com/sonirico/go-hyperliquid v0.36.0/go.mod h1:6UkIfvbqOPtCNcdC2TQ7vVPy5k8pqgoMucbGep4gCuY=
|
||||
github.com/sonirico/vago v0.11.4 h1:KlKh5iYYxKii1bhReKDIE10LPz/GPPqAcn4EvZl4t54=
|
||||
github.com/sonirico/vago v0.11.4/go.mod h1:HCfnyPHId7V+zBZ5BLfIsdHIO+ewo6+uhF1N0hxlldc=
|
||||
github.com/sonirico/vago/lol v0.1.0 h1:YjI+JAQ6enMYlpoM23w6J+1b11TJ8rqPpuD2NDHdFlA=
|
||||
github.com/sonirico/vago/lol v0.1.0/go.mod h1:k8CVrcWhKbPSX5821lt8L64z/DaST2TUaxiJOdPaSA0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@@ -272,7 +206,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
|
||||
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
|
||||
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
@@ -281,128 +214,62 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
|
||||
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1 h1:C9+KrlqS8F4SZFu+ct0Jmv2YLmzDhWsI8htK6exd3vg=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.1/go.mod h1:wXViB7paxMUrERgZrmUb+0FCqgb13Dull1JOOd8Hcj0=
|
||||
go.elastic.co/apm/v2 v2.7.1 h1:OFjARuESjBsxw7wHrEAnfSVNCHGBATXSI/kPvBARY/A=
|
||||
go.elastic.co/apm/v2 v2.7.1/go.mod h1:tQhBAjwh93b2leuAdzGwta/sP7Yc7QoKTSjeIHHDuog=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.2 h1:JPgmhFEUDfjvIrfZdWEgkwu5H2Nzhze6GFan+qoUQYo=
|
||||
go.elastic.co/apm/module/apmzerolog/v2 v2.7.2/go.mod h1:oQIxTgTMMef1FgFghymN+GCXpWhW6rpQRihV8Gjoi+w=
|
||||
go.elastic.co/apm/v2 v2.7.2 h1:0blxpxOMOcpBTz034RBqvEw806y0CDJwo/ut+2wZsHA=
|
||||
go.elastic.co/apm/v2 v2.7.2/go.mod h1:KJcwwsaouDzcLd8EviAO+y8yrfZzD6PhUCEg82bvLV4=
|
||||
go.elastic.co/fastjson v1.5.1 h1:zeh1xHrFH79aQ6Xsw7YxixvnOdAl3OSv0xch/jRDzko=
|
||||
go.elastic.co/fastjson v1.5.1/go.mod h1:WtvH5wz8z9pDOPqNYSYKoLLv/9zCWZLeejHWuvdL/EM=
|
||||
go.mongodb.org/mongo-driver v1.12.2 h1:gbWY1bJkkmUB9jjZzcdhOL8O85N9H+Vvsf2yFN0RDws=
|
||||
go.mongodb.org/mongo-driver v1.12.2/go.mod h1:/rGBTebI3XYboVmgz+Wv3Bcbl3aD0QF9zl6kDDw18rQ=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
|
||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/ratelimit v0.2.0 h1:UQE2Bgi7p2B85uP5dC2bbRtig0C+OeNRnNEafLjsLPA=
|
||||
go.uber.org/ratelimit v0.2.0/go.mod h1:YYBV4e4naJvhpitQrWJu1vCpgB7CboMe0qhltKt6mUg=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
|
||||
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
||||
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
|
||||
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.5 h1:I0hpTIvD5rII+8LgYGrHMA2d4SQPoL6u7ZvJakWKsiA=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.5/go.mod h1:dRos81TkW9C1WJt6tTaE+uV2Lo8qJT3AG2b35+CB/nQ=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.6 h1:PiJkrakkmzc5s7EfBnZOnyiLwi7o7A9fwPzN0X2uwe0=
|
||||
gopkg.in/dnaeon/go-vcr.v4 v4.0.6/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
|
||||
563
kernel/engine.go
563
kernel/engine.go
@@ -6,14 +6,17 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/provider/hyperliquid"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/provider/vergex"
|
||||
"nofx/security"
|
||||
"nofx/store"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -103,6 +106,7 @@ type Context struct {
|
||||
MultiTFMarket map[string]map[string]*market.Data `json:"-"`
|
||||
OITopDataMap map[string]*OITopData `json:"-"`
|
||||
QuantDataMap map[string]*QuantData `json:"-"`
|
||||
VergexDataMap map[string]*vergex.MarketAnalysis `json:"-"`
|
||||
OIRankingData *nofxos.OIRankingData `json:"-"` // Market-wide OI ranking data
|
||||
NetFlowRankingData *nofxos.NetFlowRankingData `json:"-"` // Market-wide fund flow ranking data
|
||||
PriceRankingData *nofxos.PriceRankingData `json:"-"` // Market-wide price gainers/losers
|
||||
@@ -182,8 +186,10 @@ type OIDeltaData struct {
|
||||
|
||||
// StrategyEngine strategy execution engine
|
||||
type StrategyEngine struct {
|
||||
config *store.StrategyConfig
|
||||
nofxosClient *nofxos.Client
|
||||
config *store.StrategyConfig
|
||||
nofxosClient *nofxos.Client
|
||||
vergexClient *vergex.Client
|
||||
vergexRankingCache map[string]*vergex.SignalRankItem
|
||||
}
|
||||
|
||||
// NewStrategyEngine creates strategy execution engine.
|
||||
@@ -216,14 +222,44 @@ func NewStrategyEngine(config *store.StrategyConfig, claw402WalletKey ...string)
|
||||
} else {
|
||||
logger.Warnf("⚠️ Failed to init claw402 data client: %v (using direct nofxos.ai)", err)
|
||||
}
|
||||
|
||||
vergexClient, err := vergex.NewClient(claw402URL, walletKey, &logger.MCPLogger{})
|
||||
if err == nil {
|
||||
logger.Infof("🔗 Vergex signals routed through claw402 (%s)", claw402URL)
|
||||
} else {
|
||||
logger.Warnf("⚠️ Failed to init Vergex claw402 client: %v", err)
|
||||
}
|
||||
return &StrategyEngine{
|
||||
config: config,
|
||||
nofxosClient: client,
|
||||
vergexClient: vergexClient,
|
||||
vergexRankingCache: make(map[string]*vergex.SignalRankItem),
|
||||
}
|
||||
}
|
||||
|
||||
return &StrategyEngine{
|
||||
config: config,
|
||||
nofxosClient: client,
|
||||
config: config,
|
||||
nofxosClient: client,
|
||||
vergexRankingCache: make(map[string]*vergex.SignalRankItem),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) usesHyperliquidNativeUniverse() bool {
|
||||
if e == nil || e.config == nil {
|
||||
return false
|
||||
}
|
||||
source := e.config.CoinSource
|
||||
if source.SourceType == "hyper_all" || source.SourceType == "hyper_main" || source.SourceType == "hyper_rank" || source.SourceType == "vergex_signal" || source.UseHyperAll || source.UseHyperMain {
|
||||
return true
|
||||
}
|
||||
for _, symbol := range source.StaticCoins {
|
||||
if market.IsXyzDexAsset(symbol) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetRiskControlConfig gets risk control configuration
|
||||
func (e *StrategyEngine) GetRiskControlConfig() store.RiskControlConfig {
|
||||
return e.config.RiskControl
|
||||
@@ -368,6 +404,27 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
||||
}
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "hyper_rank":
|
||||
coins, err := e.getHyperRankCoins(coinSource.HyperRankCategory, coinSource.HyperRankDirection, coinSource.HyperRankLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "vergex_signal":
|
||||
coins, err := e.getVergexSignalCoins(
|
||||
coinSource.VergexLimit,
|
||||
coinSource.VergexMarketType,
|
||||
coinSource.VergexChain,
|
||||
coinSource.VergexLiqBand,
|
||||
coinSource.HyperRankCategory,
|
||||
coinSource.StaticCoins,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.filterExcludedCoins(coins), nil
|
||||
|
||||
case "mixed":
|
||||
if coinSource.UseAI500 {
|
||||
poolCoins, err := e.getAI500Coins(coinSource.AI500Limit)
|
||||
@@ -586,6 +643,210 @@ func (e *StrategyEngine) getHyperMainCoins(limit int) ([]CandidateCoin, error) {
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func clampHyperRankLimit(limit int) int {
|
||||
if limit <= 0 {
|
||||
return 5
|
||||
}
|
||||
if limit > 10 {
|
||||
return 10
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) getHyperRankCoins(category, direction string, limit int) ([]CandidateCoin, error) {
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
if category == "" {
|
||||
category = "stock"
|
||||
}
|
||||
direction = strings.ToLower(strings.TrimSpace(direction))
|
||||
if direction == "" {
|
||||
direction = "gainers"
|
||||
}
|
||||
limit = clampHyperRankLimit(limit)
|
||||
|
||||
ctx := context.Background()
|
||||
var ranked []struct {
|
||||
symbol string
|
||||
info hyperliquid.CoinInfo
|
||||
cat string
|
||||
}
|
||||
|
||||
if category == "crypto" || category == "all" {
|
||||
coins, err := hyperliquid.GetPerpDexCoins(ctx, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Hyperliquid crypto ranking: %w", err)
|
||||
}
|
||||
for _, coin := range coins {
|
||||
ranked = append(ranked, struct {
|
||||
symbol string
|
||||
info hyperliquid.CoinInfo
|
||||
cat string
|
||||
}{symbol: market.Normalize(coin.Symbol + "USDT"), info: coin, cat: "crypto"})
|
||||
}
|
||||
}
|
||||
|
||||
if category != "crypto" {
|
||||
coins, err := hyperliquid.GetPerpDexCoins(ctx, "xyz")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Hyperliquid XYZ ranking: %w", err)
|
||||
}
|
||||
for _, coin := range coins {
|
||||
base := strings.TrimPrefix(coin.Symbol, "xyz:")
|
||||
cat := hyperliquid.XYZCategory(base)
|
||||
if category != "all" && cat != category {
|
||||
continue
|
||||
}
|
||||
ranked = append(ranked, struct {
|
||||
symbol string
|
||||
info hyperliquid.CoinInfo
|
||||
cat string
|
||||
}{symbol: hyperliquid.FormatCoinForAPI("xyz:" + base), info: coin, cat: cat})
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
switch direction {
|
||||
case "losers":
|
||||
return ranked[i].info.Change24hPct < ranked[j].info.Change24hPct
|
||||
case "volume":
|
||||
return ranked[i].info.Volume24h > ranked[j].info.Volume24h
|
||||
default:
|
||||
return ranked[i].info.Change24hPct > ranked[j].info.Change24hPct
|
||||
}
|
||||
})
|
||||
|
||||
if len(ranked) > limit {
|
||||
ranked = ranked[:limit]
|
||||
}
|
||||
candidates := make([]CandidateCoin, 0, len(ranked))
|
||||
source := fmt.Sprintf("hyper_rank_%s_%s", category, direction)
|
||||
for _, item := range ranked {
|
||||
candidates = append(candidates, CandidateCoin{Symbol: item.symbol, Sources: []string{source}})
|
||||
}
|
||||
logger.Infof("✅ Loaded %d Hyperliquid rank coins (%s/%s, capped at %d)", len(candidates), category, direction, limit)
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) getVergexSignalCoins(limit int, marketType, chain, liqBand, category string, selectedSymbols []string) ([]CandidateCoin, error) {
|
||||
if e.vergexClient == nil {
|
||||
return nil, fmt.Errorf("vergex signal source requires a configured claw402 wallet")
|
||||
}
|
||||
if marketType == "" {
|
||||
marketType = vergex.DefaultMarketType
|
||||
}
|
||||
chain = vergex.QueryChain(chain)
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
if limit > store.MaxCandidateCoins {
|
||||
limit = store.MaxCandidateCoins
|
||||
}
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
|
||||
ranking, err := e.vergexClient.GetSignalRanking(context.Background(), vergex.Query{
|
||||
Chain: chain,
|
||||
LiqBand: liqBand,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch Vergex signal ranking: %w", err)
|
||||
}
|
||||
|
||||
rankedItems := vergex.FilterSignalRankingItems(ranking.Items, marketType, store.MaxCandidateCoins)
|
||||
if len(rankedItems) == 0 && strings.TrimSpace(chain) != "" {
|
||||
fallbackRanking, fallbackErr := e.vergexClient.GetSignalRanking(context.Background(), vergex.Query{
|
||||
LiqBand: liqBand,
|
||||
})
|
||||
if fallbackErr == nil {
|
||||
fallbackItems := vergex.FilterSignalRankingItems(fallbackRanking.Items, marketType, store.MaxCandidateCoins)
|
||||
if len(fallbackItems) > 0 {
|
||||
logger.Infof("✅ Vergex signal ranking returned TradeFi items after retrying without chain filter (chain=%s)", chain)
|
||||
ranking = fallbackRanking
|
||||
rankedItems = fallbackItems
|
||||
}
|
||||
} else {
|
||||
logger.Warnf("⚠️ Vergex signal ranking retry without chain failed: %v", fallbackErr)
|
||||
}
|
||||
}
|
||||
e.vergexRankingCache = make(map[string]*vergex.SignalRankItem, len(rankedItems))
|
||||
for _, item := range rankedItems {
|
||||
itemCopy := item
|
||||
if symbol := vergex.TradableSymbolForMarket(item.MarketType, item.Symbol); symbol != "" {
|
||||
e.vergexRankingCache[symbol] = &itemCopy
|
||||
}
|
||||
}
|
||||
|
||||
if len(selectedSymbols) > 0 {
|
||||
candidates := make([]CandidateCoin, 0, minInt(len(selectedSymbols), limit))
|
||||
seen := make(map[string]bool)
|
||||
for _, raw := range selectedSymbols {
|
||||
symbol := vergex.TradableSymbolForMarket(marketType, raw)
|
||||
if symbol == "" || seen[symbol] {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"vergex_signal"},
|
||||
})
|
||||
seen[symbol] = true
|
||||
if len(candidates) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, fmt.Errorf("selected Claw402 symbols are not tradable %s items", marketType)
|
||||
}
|
||||
logger.Infof("✅ Loaded %d selected Vergex candidates (%s)", len(candidates), marketType)
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
items := make([]vergex.SignalRankItem, 0, limit)
|
||||
for _, item := range rankedItems {
|
||||
if category != "" && category != "all" && item.Category != category {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
if len(items) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
if category != "" && category != "all" {
|
||||
return nil, fmt.Errorf("vergex signal ranking returned no tradable %s items in category %s", marketType, category)
|
||||
}
|
||||
return nil, fmt.Errorf("vergex signal ranking returned no tradable %s items", marketType)
|
||||
}
|
||||
|
||||
candidates := make([]CandidateCoin, 0, len(items))
|
||||
for _, item := range items {
|
||||
itemCopy := item
|
||||
symbol := vergex.TradableSymbolForMarket(item.MarketType, item.Symbol)
|
||||
if symbol == "" {
|
||||
continue
|
||||
}
|
||||
e.vergexRankingCache[symbol] = &itemCopy
|
||||
candidates = append(candidates, CandidateCoin{
|
||||
Symbol: symbol,
|
||||
Sources: []string{"vergex_signal"},
|
||||
})
|
||||
}
|
||||
logger.Infof("✅ Loaded %d Vergex signal candidates (%s/%s, capped at %d)", len(candidates), marketType, withDefaultText(category, "all"), limit)
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func withDefaultText(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// External & Quant Data
|
||||
// ============================================================================
|
||||
@@ -677,6 +938,10 @@ func (e *StrategyEngine) FetchQuantData(symbol string) (*QuantData, error) {
|
||||
if !e.config.Indicators.EnableQuantData {
|
||||
return nil, nil
|
||||
}
|
||||
if e.usesHyperliquidNativeUniverse() || market.IsXyzDexAsset(symbol) {
|
||||
logger.Infof("⏭️ Skipping NofxOS quant data for Hyperliquid symbol %s; using native Hyperliquid klines/mark data only", symbol)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Use nofxos client with unified API key
|
||||
include := "oi,price"
|
||||
@@ -767,12 +1032,292 @@ func (e *StrategyEngine) FetchQuantDataBatch(symbols []string) map[string]*Quant
|
||||
return result
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) FetchVergexDataBatch(ctx context.Context, symbols []string) map[string]*vergex.MarketAnalysis {
|
||||
result := make(map[string]*vergex.MarketAnalysis)
|
||||
if e == nil || e.config == nil || e.config.CoinSource.SourceType != "vergex_signal" {
|
||||
return result
|
||||
}
|
||||
if e.vergexClient == nil {
|
||||
logger.Warnf("⚠️ Vergex signal data skipped: claw402 wallet is not configured")
|
||||
return result
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
source := e.config.CoinSource
|
||||
marketType := source.VergexMarketType
|
||||
if marketType == "" {
|
||||
marketType = vergex.DefaultMarketType
|
||||
}
|
||||
chain := source.VergexChain
|
||||
chain = vergex.QueryChain(chain)
|
||||
|
||||
seen := make(map[string]bool)
|
||||
limited := make([]string, 0, store.MaxCandidateCoins)
|
||||
for _, symbol := range symbols {
|
||||
symbol = vergexDetailSymbolForLookup(marketType, symbol)
|
||||
if symbol == "" {
|
||||
continue
|
||||
}
|
||||
if seen[symbol] {
|
||||
continue
|
||||
}
|
||||
seen[symbol] = true
|
||||
limited = append(limited, symbol)
|
||||
if len(limited) >= store.MaxCandidateCoins+store.MaxPositions {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
type vergexAnalysisResult struct {
|
||||
symbol string
|
||||
analysis *vergex.MarketAnalysis
|
||||
}
|
||||
|
||||
resultCh := make(chan vergexAnalysisResult, len(limited))
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, vergexDetailSymbolConcurrency)
|
||||
for _, symbol := range limited {
|
||||
symbol := symbol
|
||||
querySymbol := vergex.QuerySymbol(symbol)
|
||||
if querySymbol == "" {
|
||||
continue
|
||||
}
|
||||
itemMarketType := marketType
|
||||
itemCategory := ""
|
||||
var ranking *vergex.SignalRankItem
|
||||
if cached, ok := e.vergexRankingCache[symbol]; ok && cached != nil {
|
||||
ranking = cached
|
||||
if cached.MarketType != "" {
|
||||
itemMarketType = cached.MarketType
|
||||
}
|
||||
itemCategory = cached.Category
|
||||
}
|
||||
|
||||
analysis := &vergex.MarketAnalysis{
|
||||
Symbol: symbol,
|
||||
QuerySymbol: querySymbol,
|
||||
MarketType: itemMarketType,
|
||||
Ranking: ranking,
|
||||
}
|
||||
query := vergex.Query{
|
||||
MarketType: itemMarketType,
|
||||
Symbol: symbol,
|
||||
Chain: chain,
|
||||
LiqBand: source.VergexLiqBand,
|
||||
Category: itemCategory,
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
analysis.SignalLabError = ctx.Err().Error()
|
||||
analysis.HeatmapError = ctx.Err().Error()
|
||||
resultCh <- vergexAnalysisResult{symbol: symbol, analysis: analysis}
|
||||
return
|
||||
}
|
||||
e.populateVergexDetailData(ctx, analysis, query)
|
||||
if len(analysis.SignalLab) > 0 || len(analysis.Heatmap) > 0 ||
|
||||
analysis.SignalLabError != "" || analysis.HeatmapError != "" || analysis.Ranking != nil {
|
||||
resultCh <- vergexAnalysisResult{symbol: symbol, analysis: analysis}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(resultCh)
|
||||
for item := range resultCh {
|
||||
result[item.symbol] = item.analysis
|
||||
}
|
||||
|
||||
logger.Infof("📊 Vergex detail data ready for %d symbols", len(result))
|
||||
return result
|
||||
}
|
||||
|
||||
func vergexDetailSymbolForLookup(marketType, symbol string) string {
|
||||
return vergex.TradableSymbolForMarket(marketType, symbol)
|
||||
}
|
||||
|
||||
const (
|
||||
vergexDetailRequestTimeout = 45 * time.Second
|
||||
vergexDetailSymbolConcurrency = 2
|
||||
)
|
||||
|
||||
func (e *StrategyEngine) populateVergexDetailData(ctx context.Context, analysis *vergex.MarketAnalysis, query vergex.Query) {
|
||||
type endpointResult struct {
|
||||
name string
|
||||
body json.RawMessage
|
||||
err error
|
||||
}
|
||||
|
||||
run := func(name string, fetch func(context.Context, vergex.Query) (json.RawMessage, error), out chan<- endpointResult) {
|
||||
requestCtx, cancel := context.WithTimeout(ctx, vergexDetailRequestTimeout)
|
||||
defer cancel()
|
||||
body, err := fetch(requestCtx, query)
|
||||
out <- endpointResult{name: name, body: body, err: err}
|
||||
}
|
||||
|
||||
out := make(chan endpointResult, 2)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
run("signal-lab", e.fetchVergexSignalLabWithFallback, out)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
run("heatmap", e.fetchVergexHeatmapWithFallback, out)
|
||||
}()
|
||||
wg.Wait()
|
||||
close(out)
|
||||
|
||||
for item := range out {
|
||||
switch item.name {
|
||||
case "signal-lab":
|
||||
if item.err != nil {
|
||||
logger.Warnf("⚠️ Failed to fetch Vergex signal-lab for %s: %v", analysis.Symbol, item.err)
|
||||
analysis.SignalLabError = item.err.Error()
|
||||
} else {
|
||||
analysis.SignalLab = item.body
|
||||
}
|
||||
case "heatmap":
|
||||
if item.err != nil {
|
||||
logger.Warnf("⚠️ Failed to fetch Vergex heatmap for %s: %v", analysis.Symbol, item.err)
|
||||
analysis.HeatmapError = item.err.Error()
|
||||
} else {
|
||||
analysis.Heatmap = item.body
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) fetchVergexSignalLabWithFallback(ctx context.Context, query vergex.Query) (json.RawMessage, error) {
|
||||
var lastErr error
|
||||
for idx, candidate := range vergexDetailQueryCandidates(query) {
|
||||
body, err := e.vergexClient.GetSignalLab(ctx, candidate)
|
||||
if err == nil {
|
||||
if idx > 0 {
|
||||
logger.Infof("✅ Vergex signal-lab succeeded with fallback marketType=%s chain=%s", candidate.MarketType, withDefaultText(candidate.Chain, "default"))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !isRetryableVergexDetailError(err) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) fetchVergexHeatmapWithFallback(ctx context.Context, query vergex.Query) (json.RawMessage, error) {
|
||||
var lastErr error
|
||||
for idx, candidate := range vergexDetailQueryCandidates(query) {
|
||||
body, err := e.vergexClient.GetCostLiquidationHeatmap(ctx, candidate)
|
||||
if err == nil {
|
||||
if idx > 0 {
|
||||
logger.Infof("✅ Vergex heatmap succeeded with fallback marketType=%s chain=%s", candidate.MarketType, withDefaultText(candidate.Chain, "default"))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
lastErr = err
|
||||
if !isRetryableVergexDetailError(err) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func vergexDetailQueryCandidates(query vergex.Query) []vergex.Query {
|
||||
marketTypes := vergexDetailMarketTypeCandidates(query)
|
||||
chains := uniqueValues(query.Chain, "mainnet", "")
|
||||
|
||||
candidates := make([]vergex.Query, 0, len(marketTypes)*len(chains))
|
||||
for _, marketType := range marketTypes {
|
||||
for _, chain := range chains {
|
||||
candidate := query
|
||||
candidate.MarketType = marketType
|
||||
candidate.Chain = chain
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func vergexDetailMarketTypeCandidates(query vergex.Query) []string {
|
||||
if isVergexAllMarketType(query.MarketType) {
|
||||
if market.IsXyzDexAsset(query.Symbol) {
|
||||
return uniqueNonEmpty(vergex.DefaultMarketType, "hip3-perp", "hip3Perp", "core_perp")
|
||||
}
|
||||
return uniqueNonEmpty("core_perp", vergex.DefaultMarketType, "hip3-perp", "hip3Perp")
|
||||
}
|
||||
values := []string{query.MarketType, vergex.DefaultMarketType, "hip3-perp", "hip3Perp", "core_perp"}
|
||||
return uniqueNonEmpty(values...)
|
||||
}
|
||||
|
||||
func isVergexAllMarketType(marketType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(marketType)) {
|
||||
case "", "all", "any", "ranking", "signal-ranking", "signal_ranking", "claw402", "vergex":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isRetryableVergexDetailError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "invalid markettype") ||
|
||||
strings.Contains(msg, "invalid_request") ||
|
||||
strings.Contains(msg, "invalid chain") ||
|
||||
strings.Contains(msg, "market not found") ||
|
||||
strings.Contains(msg, "not_found")
|
||||
}
|
||||
|
||||
func uniqueNonEmpty(values ...string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func uniqueValues(values ...string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FetchOIRankingData fetches market-wide OI ranking data
|
||||
func (e *StrategyEngine) FetchOIRankingData() *nofxos.OIRankingData {
|
||||
indicators := e.config.Indicators
|
||||
if !indicators.EnableOIRanking {
|
||||
return nil
|
||||
}
|
||||
if e.usesHyperliquidNativeUniverse() {
|
||||
logger.Infof("⏭️ Skipping NofxOS OI ranking for Hyperliquid strategy; native Hyperliquid universe is the source of truth")
|
||||
return nil
|
||||
}
|
||||
|
||||
duration := indicators.OIRankingDuration
|
||||
if duration == "" {
|
||||
@@ -804,6 +1349,10 @@ func (e *StrategyEngine) FetchNetFlowRankingData() *nofxos.NetFlowRankingData {
|
||||
if !indicators.EnableNetFlowRanking {
|
||||
return nil
|
||||
}
|
||||
if e.usesHyperliquidNativeUniverse() {
|
||||
logger.Infof("⏭️ Skipping NofxOS netflow ranking for Hyperliquid strategy; native Hyperliquid universe is the source of truth")
|
||||
return nil
|
||||
}
|
||||
|
||||
duration := indicators.NetFlowRankingDuration
|
||||
if duration == "" {
|
||||
@@ -836,6 +1385,10 @@ func (e *StrategyEngine) FetchPriceRankingData() *nofxos.PriceRankingData {
|
||||
if !indicators.EnablePriceRanking {
|
||||
return nil
|
||||
}
|
||||
if e.usesHyperliquidNativeUniverse() {
|
||||
logger.Infof("⏭️ Skipping NofxOS price ranking for Hyperliquid strategy; native Hyperliquid universe is the source of truth")
|
||||
return nil
|
||||
}
|
||||
|
||||
durations := indicators.PriceRankingDuration
|
||||
if durations == "" {
|
||||
|
||||
@@ -84,6 +84,8 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
|
||||
return nil, fmt.Errorf("failed to fetch market data: %w", err)
|
||||
}
|
||||
}
|
||||
pruneCandidateCoinsWithoutMarketData(ctx)
|
||||
enrichVergexDataWithStrategy(ctx, engine)
|
||||
|
||||
// Ensure OITopDataMap is initialized
|
||||
if ctx.OITopDataMap == nil {
|
||||
@@ -141,6 +143,30 @@ func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *S
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func enrichVergexDataWithStrategy(ctx *Context, engine *StrategyEngine) {
|
||||
if ctx == nil || engine == nil || ctx.VergexDataMap != nil {
|
||||
return
|
||||
}
|
||||
if engine.GetConfig().CoinSource.SourceType != "vergex_signal" {
|
||||
return
|
||||
}
|
||||
symbolSet := make(map[string]bool)
|
||||
symbols := make([]string, 0, len(ctx.CandidateCoins)+len(ctx.Positions))
|
||||
for _, coin := range ctx.CandidateCoins {
|
||||
if !symbolSet[coin.Symbol] {
|
||||
symbolSet[coin.Symbol] = true
|
||||
symbols = append(symbols, coin.Symbol)
|
||||
}
|
||||
}
|
||||
for _, pos := range ctx.Positions {
|
||||
if !symbolSet[pos.Symbol] {
|
||||
symbolSet[pos.Symbol] = true
|
||||
symbols = append(symbols, pos.Symbol)
|
||||
}
|
||||
}
|
||||
ctx.VergexDataMap = engine.FetchVergexDataBatch(nil, symbols)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Market Data Fetching
|
||||
// ============================================================================
|
||||
@@ -223,6 +249,21 @@ func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func pruneCandidateCoinsWithoutMarketData(ctx *Context) {
|
||||
if ctx == nil || len(ctx.CandidateCoins) == 0 || len(ctx.MarketDataMap) == 0 {
|
||||
return
|
||||
}
|
||||
kept := make([]CandidateCoin, 0, len(ctx.CandidateCoins))
|
||||
for _, coin := range ctx.CandidateCoins {
|
||||
if _, ok := ctx.MarketDataMap[coin.Symbol]; ok {
|
||||
kept = append(kept, coin)
|
||||
continue
|
||||
}
|
||||
logger.Infof("⚠️ Skipping candidate %s in AI prompt: no valid market/K-line data", coin.Symbol)
|
||||
}
|
||||
ctx.CandidateCoins = kept
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI Response Parsing
|
||||
// ============================================================================
|
||||
|
||||
@@ -3,6 +3,7 @@ package kernel
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
@@ -33,10 +34,18 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi
|
||||
}
|
||||
|
||||
if d.Action == "open_long" || d.Action == "open_short" {
|
||||
// Asset tiering for validation:
|
||||
// - BTC/ETH crypto perps use the BTC/ETH tier (typically 5x equity).
|
||||
// - Hyperliquid XYZ assets (US equities, commodities, forex) are
|
||||
// also treated as the higher tier — they are not crypto altcoins
|
||||
// and the user's quick-trade flow shows them at the higher cap,
|
||||
// so the validator must match.
|
||||
// - Everything else is altcoin (1x equity by default).
|
||||
maxLeverage := altcoinLeverage
|
||||
posRatio := altcoinPosRatio
|
||||
maxPositionValue := accountEquity * posRatio
|
||||
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
|
||||
isMajor := d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" || market.IsXyzDexAsset(d.Symbol)
|
||||
if isMajor {
|
||||
maxLeverage = btcEthLeverage
|
||||
posRatio = btcEthPosRatio
|
||||
maxPositionValue = accountEquity * posRatio
|
||||
@@ -69,9 +78,12 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi
|
||||
|
||||
tolerance := maxPositionValue * 0.01
|
||||
if d.PositionSizeUSD > maxPositionValue+tolerance {
|
||||
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
|
||||
switch {
|
||||
case d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT":
|
||||
return fmt.Errorf("BTC/ETH single coin position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", maxPositionValue, posRatio, d.PositionSizeUSD)
|
||||
} else {
|
||||
case market.IsXyzDexAsset(d.Symbol):
|
||||
return fmt.Errorf("%s position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", d.Symbol, maxPositionValue, posRatio, d.PositionSizeUSD)
|
||||
default:
|
||||
return fmt.Errorf("altcoin single coin position value cannot exceed %.0f USDT (%.1fx account equity), actual: %.0f", maxPositionValue, posRatio, d.PositionSizeUSD)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"nofx/market"
|
||||
"nofx/provider/nofxos"
|
||||
"nofx/provider/vergex"
|
||||
"nofx/store"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -18,34 +19,45 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
|
||||
var sb strings.Builder
|
||||
riskControl := e.config.RiskControl
|
||||
promptSections := e.config.PromptSections
|
||||
// System prompts are intentionally English-only. UI copy can be localized,
|
||||
// but the model contract should stay language-stable for an international
|
||||
// open-source project and for reproducible trading behavior.
|
||||
lang := LangEnglish
|
||||
zh := false
|
||||
singleSymbol, primarySymbol := e.singleSymbolInfo()
|
||||
|
||||
if e.usesVergexSignalPrompt() {
|
||||
return e.buildVergexSystemPrompt(accountEquity, variant, lang, zh, singleSymbol, primarySymbol)
|
||||
}
|
||||
|
||||
// 0. Data Dictionary & Schema (ensure AI understands all fields)
|
||||
lang := e.GetLanguage()
|
||||
schemaPrompt := GetSchemaPrompt(lang)
|
||||
sb.WriteString(schemaPrompt)
|
||||
sb.WriteString(GetSchemaPrompt(lang))
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString("---\n\n")
|
||||
|
||||
// 1. Role definition (editable)
|
||||
if promptSections.RoleDefinition != "" {
|
||||
sb.WriteString(promptSections.RoleDefinition)
|
||||
// 1. Role definition (editable; falls back to a generic intro in the
|
||||
// correct language so we don't mix EN headings with ZH custom text).
|
||||
roleDefinition := englishOnlyPromptSection(promptSections.RoleDefinition)
|
||||
if roleDefinition != "" {
|
||||
sb.WriteString(roleDefinition)
|
||||
sb.WriteString("\n\n")
|
||||
} else if zh {
|
||||
sb.WriteString("# 你是一名专业的 Hyperliquid USDC 多资产交易 AI\n\n")
|
||||
sb.WriteString("你的任务是基于提供的市场数据做出交易决策。\n\n")
|
||||
} else {
|
||||
sb.WriteString("# You are a professional cryptocurrency trading AI\n\n")
|
||||
sb.WriteString("Your task is to make trading decisions based on provided market data.\n\n")
|
||||
sb.WriteString("# You are a professional Hyperliquid USDC multi-asset trading AI\n\n")
|
||||
sb.WriteString("Your task is to make trading decisions based on the provided market data.\n\n")
|
||||
}
|
||||
|
||||
// 2. Trading mode variant
|
||||
switch strings.ToLower(strings.TrimSpace(variant)) {
|
||||
case "aggressive":
|
||||
sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts, can build positions in batches when confidence ≥ 70\n- Allow higher positions, but must strictly set stop-loss and explain risk-reward ratio\n\n")
|
||||
case "conservative":
|
||||
sb.WriteString("## Mode: Conservative\n- Only open positions when multiple signals resonate\n- Prioritize cash preservation, must pause for multiple periods after consecutive losses\n\n")
|
||||
case "scalping":
|
||||
sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n")
|
||||
}
|
||||
writeModeVariant(&sb, variant, zh)
|
||||
|
||||
// 3. Hard constraints (risk control)
|
||||
// 3. Hard constraints (risk control).
|
||||
//
|
||||
// `singleSymbol` is true for strategies that deliberately trade just one
|
||||
// instrument (the quick-create flow, single-asset templates). For those,
|
||||
// the "BTC/ETH vs Altcoin" two-tier categorization is irrelevant and
|
||||
// actively misleading — we surface a single position-value limit instead.
|
||||
btcEthPosValueRatio := riskControl.BTCETHMaxPositionValueRatio
|
||||
if btcEthPosValueRatio <= 0 {
|
||||
btcEthPosValueRatio = 5.0
|
||||
@@ -55,168 +67,641 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
|
||||
altcoinPosValueRatio = 1.0
|
||||
}
|
||||
|
||||
sb.WriteString("# Hard Constraints (Risk Control)\n\n")
|
||||
sb.WriteString("## CODE ENFORCED (Backend validation, cannot be bypassed):\n")
|
||||
sb.WriteString(fmt.Sprintf("- Max Positions: %d coins simultaneously\n", riskControl.MaxPositions))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (Altcoins): max %.0f USDT (= equity %.0f × %.1fx)\n",
|
||||
accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (BTC/ETH): max %.0f USDT (= equity %.0f × %.1fx)\n",
|
||||
accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Max Margin Usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min Position Size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
|
||||
sb.WriteString("## AI GUIDED (Recommended, you should follow):\n")
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage: Altcoins max %dx | BTC/ETH max %dx\n",
|
||||
riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
|
||||
sb.WriteString(fmt.Sprintf("- Risk-Reward Ratio: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min Confidence: ≥%d to open position\n\n", riskControl.MinConfidence))
|
||||
|
||||
// Position sizing guidance
|
||||
sb.WriteString("## Position Sizing Guidance\n")
|
||||
sb.WriteString("Calculate `position_size_usd` based on your confidence and the Position Value Limits above:\n")
|
||||
sb.WriteString("- High confidence (≥85): Use 80-100%% of max position value limit\n")
|
||||
sb.WriteString("- Medium confidence (70-84): Use 50-80%% of max position value limit\n")
|
||||
sb.WriteString("- Low confidence (60-69): Use 30-50%% of max position value limit\n")
|
||||
sb.WriteString(fmt.Sprintf("- Example: With equity %.0f and BTC/ETH ratio %.1fx, max is %.0f USDT\n",
|
||||
accountEquity, btcEthPosValueRatio, accountEquity*btcEthPosValueRatio))
|
||||
sb.WriteString("- **DO NOT** just use available_balance as position_size_usd. Use the Position Value Limits!\n\n")
|
||||
writeHardConstraints(&sb, accountEquity, riskControl, btcEthPosValueRatio, altcoinPosValueRatio, singleSymbol, primarySymbol, zh)
|
||||
|
||||
// 4. Trading frequency (editable)
|
||||
if promptSections.TradingFrequency != "" {
|
||||
sb.WriteString(promptSections.TradingFrequency)
|
||||
tradingFrequency := englishOnlyPromptSection(promptSections.TradingFrequency)
|
||||
if tradingFrequency != "" {
|
||||
sb.WriteString(tradingFrequency)
|
||||
sb.WriteString("\n\n")
|
||||
} else if zh {
|
||||
sb.WriteString("# ⏱️ 交易频率提醒\n\n")
|
||||
sb.WriteString("- 优秀交易员: 每日 2-4 单 ≈ 每小时 0.1-0.2 单\n")
|
||||
sb.WriteString("- 每小时 > 2 单 = 过度交易\n")
|
||||
sb.WriteString("- 单笔持仓时长 ≥ 45-90 分钟\n")
|
||||
sb.WriteString("如果你发现自己每个周期都在交易 → 入场标准过低; 如果不到 45 分钟就平仓 → 太冲动。\n\n")
|
||||
} else {
|
||||
sb.WriteString("# ⏱️ Trading Frequency Awareness\n\n")
|
||||
sb.WriteString("- Excellent traders: 2-4 trades/day ≈ 0.1-0.2 trades/hour\n")
|
||||
sb.WriteString("- >2 trades/hour = Overtrading\n")
|
||||
sb.WriteString("- Single position hold time ≥ 30-60 minutes\n")
|
||||
sb.WriteString("If you find yourself trading every period → standards too low; if closing positions < 30 minutes → too impatient.\n\n")
|
||||
sb.WriteString("- >2 trades/hour = overtrading\n")
|
||||
sb.WriteString("- Single position hold time ≥ 45-90 minutes\n")
|
||||
sb.WriteString("If you find yourself trading every cycle → standards too low; if closing positions < 45 minutes → too impulsive.\n\n")
|
||||
}
|
||||
|
||||
// 5. Entry standards (editable)
|
||||
if promptSections.EntryStandards != "" {
|
||||
sb.WriteString(promptSections.EntryStandards)
|
||||
sb.WriteString("\n\nYou have the following indicator data:\n")
|
||||
e.writeAvailableIndicators(&sb)
|
||||
sb.WriteString(fmt.Sprintf("\n**Confidence ≥ %d** required to open positions.\n\n", riskControl.MinConfidence))
|
||||
entryStandards := englishOnlyPromptSection(promptSections.EntryStandards)
|
||||
if entryStandards != "" {
|
||||
sb.WriteString(entryStandards)
|
||||
if zh {
|
||||
sb.WriteString("\n\n你拥有以下指标数据:\n")
|
||||
} else {
|
||||
sb.WriteString("\n\nYou have the following indicator data:\n")
|
||||
}
|
||||
e.writeAvailableIndicators(&sb, zh)
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("\n**置信度 ≥ %d** 才能开仓。\n\n", riskControl.MinConfidence))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("\n**Confidence ≥ %d** required to open positions.\n\n", riskControl.MinConfidence))
|
||||
}
|
||||
} else if zh {
|
||||
sb.WriteString("# 🎯 入场标准 (严格)\n\n")
|
||||
sb.WriteString("只有当多重信号共振时才开仓。你拥有:\n")
|
||||
e.writeAvailableIndicators(&sb, zh)
|
||||
sb.WriteString(fmt.Sprintf("\n请自由使用任何有效的分析方法, 但**置信度 ≥ %d** 才能开仓; 避免低质量行为, 如单一指标、信号矛盾、横盘震荡、平仓后立刻再开等。\n\n", riskControl.MinConfidence))
|
||||
} else {
|
||||
sb.WriteString("# 🎯 Entry Standards (Strict)\n\n")
|
||||
sb.WriteString("Only open positions when multiple signals resonate. You have:\n")
|
||||
e.writeAvailableIndicators(&sb)
|
||||
sb.WriteString(fmt.Sprintf("\nFeel free to use any effective analysis method, but **confidence ≥ %d** required to open positions; avoid low-quality behaviors such as single indicators, contradictory signals, sideways consolidation, reopening immediately after closing, etc.\n\n", riskControl.MinConfidence))
|
||||
e.writeAvailableIndicators(&sb, zh)
|
||||
sb.WriteString(fmt.Sprintf("\nFeel free to use any effective analysis method, but **confidence ≥ %d** is required to open positions; avoid low-quality behaviors such as single-indicator entries, contradictory signals, sideways chop, or re-entering immediately after a close.\n\n", riskControl.MinConfidence))
|
||||
}
|
||||
|
||||
// 6. Decision process (editable)
|
||||
if promptSections.DecisionProcess != "" {
|
||||
sb.WriteString(promptSections.DecisionProcess)
|
||||
decisionProcess := englishOnlyPromptSection(promptSections.DecisionProcess)
|
||||
if decisionProcess != "" {
|
||||
sb.WriteString(decisionProcess)
|
||||
sb.WriteString("\n\n")
|
||||
} else if zh {
|
||||
sb.WriteString("# 📋 决策流程\n\n")
|
||||
sb.WriteString("1. 检查持仓 → 是否需要止盈止损\n")
|
||||
sb.WriteString("2. 扫描候选标的 + 多周期 → 是否有强信号\n")
|
||||
sb.WriteString("3. 先写思维链, 再输出结构化 JSON\n\n")
|
||||
} else {
|
||||
sb.WriteString("# 📋 Decision Process\n\n")
|
||||
sb.WriteString("1. Check positions → Should we take profit/stop-loss\n")
|
||||
sb.WriteString("2. Scan candidate coins + multi-timeframe → Are there strong signals\n")
|
||||
sb.WriteString("1. Check positions → take profit / stop loss?\n")
|
||||
sb.WriteString("2. Scan candidates + multi-timeframe → are there strong signals?\n")
|
||||
sb.WriteString("3. Write chain of thought first, then output structured JSON\n\n")
|
||||
}
|
||||
|
||||
// 7. Output format
|
||||
sb.WriteString("# Output Format (Strictly Follow)\n\n")
|
||||
sb.WriteString("**Must use XML tags <reasoning> and <decision> to separate chain of thought and decision JSON, avoiding parsing errors**\n\n")
|
||||
sb.WriteString("## Format Requirements\n\n")
|
||||
sb.WriteString("<reasoning>\n")
|
||||
sb.WriteString("Your chain of thought analysis...\n")
|
||||
sb.WriteString("- Briefly analyze your thinking process \n")
|
||||
sb.WriteString("</reasoning>\n\n")
|
||||
sb.WriteString("<decision>\n")
|
||||
sb.WriteString("Step 2: JSON decision array\n\n")
|
||||
sb.WriteString("```json\n[\n")
|
||||
// Use the actual configured position value ratio for BTC/ETH in the example
|
||||
examplePositionSize := accountEquity * btcEthPosValueRatio
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n",
|
||||
riskControl.BTCETHMaxLeverage, examplePositionSize))
|
||||
sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n")
|
||||
sb.WriteString("]\n```\n")
|
||||
sb.WriteString("</decision>\n\n")
|
||||
sb.WriteString("## Field Description\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (opening recommended ≥ %d)\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- **IMPORTANT**: All numeric values must be calculated numbers, NOT formulas/expressions (e.g., use `27.76` not `3000 * 0.01`)\n\n")
|
||||
// 7. Output format — schema spec stays in English (this is a parser
|
||||
// contract; reasoning copy is localized below).
|
||||
writeOutputFormat(&sb, accountEquity, btcEthPosValueRatio, riskControl, singleSymbol, primarySymbol, zh)
|
||||
|
||||
// 8. Custom Prompt
|
||||
if e.config.CustomPrompt != "" {
|
||||
sb.WriteString("# 📌 Personalized Trading Strategy\n\n")
|
||||
sb.WriteString(e.config.CustomPrompt)
|
||||
// 8. Custom Prompt.
|
||||
//
|
||||
// For single-symbol Hyperliquid XYZ assets (US equities, commodities,
|
||||
// forex), we replace any stored CustomPrompt with a built-in English
|
||||
// stock-trader template. This serves two purposes:
|
||||
// 1. The auto-generated CustomPrompt from the quick-create flow used
|
||||
// to be Chinese (matching UI language), which produced an
|
||||
// incoherent mixed-language final prompt that confused the LLM.
|
||||
// 2. It guarantees a stock-specific, US-equity-tuned briefing
|
||||
// regardless of when the strategy was first created.
|
||||
customPrompt := englishOnlyPromptSection(e.config.CustomPrompt)
|
||||
if singleSymbol && market.IsXyzDexAsset(primarySymbol) {
|
||||
customPrompt = buildXYZStockCustomPrompt(primarySymbol)
|
||||
}
|
||||
|
||||
if customPrompt != "" {
|
||||
if zh {
|
||||
sb.WriteString("# 📌 个性化交易策略\n\n")
|
||||
} else {
|
||||
sb.WriteString("# 📌 Personalized Trading Strategy\n\n")
|
||||
}
|
||||
sb.WriteString(customPrompt)
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString("Note: The above personalized strategy is a supplement to the basic rules and cannot violate the basic risk control principles.\n")
|
||||
if zh {
|
||||
sb.WriteString("说明: 上述个性化策略是基础规则的补充, 不能违反基础风控原则。\n")
|
||||
} else {
|
||||
sb.WriteString("Note: the above personalized strategy supplements the basic rules and may not violate the core risk controls.\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) {
|
||||
func (e *StrategyEngine) usesVergexSignalPrompt() bool {
|
||||
if e == nil || e.config == nil {
|
||||
return false
|
||||
}
|
||||
coinSource := e.config.CoinSource
|
||||
sourceType := strings.ToLower(strings.TrimSpace(coinSource.SourceType))
|
||||
return sourceType == "vergex_signal" ||
|
||||
sourceType == "claw402" ||
|
||||
sourceType == "claw402_vergex" ||
|
||||
coinSource.VergexMarketType != "" ||
|
||||
coinSource.VergexChain != "" ||
|
||||
coinSource.VergexLimit > 0
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant string, lang Language, zh bool, singleSymbol bool, primarySymbol string) string {
|
||||
var sb strings.Builder
|
||||
riskControl := e.config.RiskControl
|
||||
|
||||
writeVergexSchemaPrompt(&sb, zh)
|
||||
sb.WriteString("\n\n---\n\n")
|
||||
|
||||
if zh {
|
||||
sb.WriteString("# 你是 NOFX Claw402 自动交易员\n\n")
|
||||
sb.WriteString("你的任务是交易 Claw402.ai/Vergex 本轮榜单返回的 Hyperliquid 可交易标的。只允许交易本轮候选标的和已有持仓,不要自行发明代码或切换到榜单外标的。\n\n")
|
||||
sb.WriteString("# 决策数据优先级\n\n")
|
||||
sb.WriteString("1. Claw402.ai Signal Ranking: 决定本轮候选池、排名、方向和类别。\n")
|
||||
sb.WriteString("2. Claw402.ai Signal Lab: 用于确认趋势、动量、事件或模型信号;这是开仓前的核心确认数据。\n")
|
||||
sb.WriteString("3. Claw402.ai Cost/Liquidation Heatmap: 用于识别清算密集区、成本区、止损位置和止盈目标。\n")
|
||||
sb.WriteString("4. 原始 OHLCV K 线: 用于验证入场时机、趋势结构、波动和风险回报。\n\n")
|
||||
sb.WriteString("# 交易原则\n\n")
|
||||
sb.WriteString("- 先管理已有持仓,再考虑新开仓。\n")
|
||||
sb.WriteString("- 开仓需要 Signal Lab、热力图和 K 线方向大体一致;任一关键数据缺失或互相冲突时,默认等待。\n")
|
||||
sb.WriteString("- 不要把 Claw402 排名当作唯一买入理由;排名只是候选池,开仓必须经过详情数据和 K 线确认。\n")
|
||||
sb.WriteString("- 本轮 Candidate Coins 中的标的都是允许交易的候选;如果某个标的详情缺失,只能降低置信度或等待,不能说它不属于可交易范围。\n")
|
||||
sb.WriteString("- 如果 Signal Lab 或热力图没有出现在该标的的 Vergex Claw402 Signals 里,必须在 reasoning 中说明缺失;如果已经出现,则不能声称该标的缺少该数据。\n")
|
||||
sb.WriteString("- 防止频繁开平仓:非止损或强止盈情况下,开仓后至少持有 45 分钟;小亏小赚的噪音区优先持有到 90 分钟;平仓后同一标的 90 分钟内不重新进场;每小时最多 1 次新开仓。\n")
|
||||
sb.WriteString("- 止损必须放在无效点之外;止盈优先放在热力图阻力/清算区域或满足风险回报的位置。\n\n")
|
||||
} else {
|
||||
sb.WriteString("# You are the NOFX Claw402 auto-trader\n\n")
|
||||
sb.WriteString("Trade only Hyperliquid instruments returned by this cycle's Claw402.ai/Vergex board. You may trade only the current candidate symbols and existing positions; never invent tickers or rotate outside the provided universe.\n\n")
|
||||
sb.WriteString("# Decision Data Priority\n\n")
|
||||
sb.WriteString("1. Claw402.ai Signal Ranking: candidate pool, rank, direction and category.\n")
|
||||
sb.WriteString("2. Claw402.ai Signal Lab: trend, momentum, event/model confirmation; this is the core pre-entry confirmation source.\n")
|
||||
sb.WriteString("3. Claw402.ai Cost/Liquidation Heatmap: crowded liquidation/cost zones, stop placement and target zones.\n")
|
||||
sb.WriteString("4. Raw OHLCV candles: entry timing, trend structure, volatility and risk/reward validation.\n\n")
|
||||
sb.WriteString("# Trading Rules\n\n")
|
||||
sb.WriteString("- Manage existing positions before opening new ones.\n")
|
||||
sb.WriteString("- Open only when Signal Lab, heatmap and raw candles broadly agree; wait when key data is missing or contradictory.\n")
|
||||
sb.WriteString("- Ranking alone is not an entry reason; it only defines the candidate pool.\n")
|
||||
sb.WriteString("- Every symbol in Candidate Coins is part of the allowed trading universe; missing detail can lower confidence or trigger waiting, but does not make the symbol non-tradable.\n")
|
||||
sb.WriteString("- If Signal Lab or heatmap is absent from that symbol's Vergex Claw402 Signals, state it in reasoning; if it is present, never claim the symbol lacks that data.\n")
|
||||
sb.WriteString("- Avoid churn: unless stopping out or taking a strong profit, hold new positions for at least 45 minutes; avoid flat/noise closes until roughly 90 minutes; after closing a symbol, wait 90 minutes before re-entry; open at most 1 new position per hour.\n")
|
||||
sb.WriteString("- Stops must sit beyond invalidation; targets should prefer heatmap resistance/liquidation zones or valid risk/reward levels.\n\n")
|
||||
}
|
||||
|
||||
writeModeVariant(&sb, variant, zh)
|
||||
|
||||
altcoinPosValueRatio := riskControl.AltcoinMaxPositionValueRatio
|
||||
if altcoinPosValueRatio <= 0 {
|
||||
altcoinPosValueRatio = 1.0
|
||||
}
|
||||
writeVergexHardConstraints(&sb, accountEquity, riskControl, altcoinPosValueRatio, zh)
|
||||
writeVergexOutputFormat(&sb, accountEquity, riskControl, altcoinPosValueRatio, singleSymbol, primarySymbol, zh)
|
||||
|
||||
customPrompt := englishOnlyPromptSection(e.config.CustomPrompt)
|
||||
if customPrompt != "" {
|
||||
sb.WriteString("# User Preference\n\n")
|
||||
sb.WriteString(customPrompt)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func englishOnlyPromptSection(section string) string {
|
||||
trimmed := strings.TrimSpace(section)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if detectLanguage(trimmed) == LangChinese {
|
||||
return ""
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func writeVergexSchemaPrompt(sb *strings.Builder, zh bool) {
|
||||
if zh {
|
||||
sb.WriteString("# Claw402.ai TradeFi 数据说明\n\n")
|
||||
sb.WriteString("- Equity: 账户总权益,包含浮动盈亏,单位 USDT。\n")
|
||||
sb.WriteString("- Balance: 可用余额,用于判断还能否开新仓,单位 USDT。\n")
|
||||
sb.WriteString("- Margin: 当前保证金使用率,越高风险越大。\n")
|
||||
sb.WriteString("- Position: 当前持仓,包含方向、进场价、杠杆、未实现盈亏、强平价。\n")
|
||||
sb.WriteString("- Claw402 Ranking: 本轮可交易候选池、排名、方向和类别。\n")
|
||||
sb.WriteString("- Signal Lab: Claw402 对单个标的的深度信号,用于确认趋势和质量。\n")
|
||||
sb.WriteString("- Cost/Liquidation Heatmap: 成本区与清算密集区,用于止损、止盈和拥挤风险判断。\n")
|
||||
sb.WriteString("- Raw OHLCV Kline: 原始 K 线,用于确认趋势结构、入场位置和风险回报。\n")
|
||||
} else {
|
||||
sb.WriteString("# Claw402.ai TradeFi Data Guide\n\n")
|
||||
sb.WriteString("- Equity: total account value including unrealized PnL, in USDT.\n")
|
||||
sb.WriteString("- Balance: available balance for new positions, in USDT.\n")
|
||||
sb.WriteString("- Margin: current margin usage; higher means more risk.\n")
|
||||
sb.WriteString("- Position: current holdings with side, entry, leverage, unrealized PnL and liquidation price.\n")
|
||||
sb.WriteString("- Claw402 Ranking: tradable candidate pool, rank, direction and category for this cycle.\n")
|
||||
sb.WriteString("- Signal Lab: per-symbol Claw402 deep signal used to confirm trend and quality.\n")
|
||||
sb.WriteString("- Cost/Liquidation Heatmap: cost and liquidation clusters used for stops, targets and crowding risk.\n")
|
||||
sb.WriteString("- Raw OHLCV Kline: raw candles used for trend structure, entry timing and risk/reward.\n")
|
||||
}
|
||||
}
|
||||
|
||||
func writeVergexHardConstraints(sb *strings.Builder, accountEquity float64, riskControl store.RiskControlConfig, tradeFiPositionValueRatio float64, zh bool) {
|
||||
maxPositionValue := accountEquity * tradeFiPositionValueRatio
|
||||
if zh {
|
||||
sb.WriteString("# 风控硬约束\n\n")
|
||||
sb.WriteString("## 后端强制\n")
|
||||
sb.WriteString(fmt.Sprintf("- 最大持仓数: 同时 %d 个 Claw402 候选标的\n", riskControl.MaxPositions))
|
||||
sb.WriteString(fmt.Sprintf("- 单仓最大名义价值: %.0f USDT (= 权益 %.0f × %.1fx)\n", maxPositionValue, accountEquity, tradeFiPositionValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- 最大保证金占用: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- 最小下单金额: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
sb.WriteString("## AI 建议\n")
|
||||
sb.WriteString(fmt.Sprintf("- 交易杠杆: Claw402 候选标的最高 %dx\n", riskControl.AltcoinMaxLeverage))
|
||||
sb.WriteString(fmt.Sprintf("- 风险回报比: ≥1:%.1f\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- 最小置信度: ≥%d 才能开仓\n\n", riskControl.MinConfidence))
|
||||
sb.WriteString("# 仓位大小\n\n")
|
||||
sb.WriteString("根据置信度和单仓最大名义价值填写 `position_size_usd`:\n")
|
||||
sb.WriteString("- 高置信 (≥85): 使用上限的 80-100%\n")
|
||||
sb.WriteString("- 中置信 (70-84): 使用上限的 50-80%\n")
|
||||
sb.WriteString("- 低置信 (60-69): 使用上限的 30-50%\n")
|
||||
sb.WriteString("- 不要直接把 available_balance 当作 position_size_usd。\n\n")
|
||||
} else {
|
||||
sb.WriteString("# Hard Risk Constraints\n\n")
|
||||
sb.WriteString("## Backend enforced\n")
|
||||
sb.WriteString(fmt.Sprintf("- Max positions: %d Claw402 candidate instruments at the same time\n", riskControl.MaxPositions))
|
||||
sb.WriteString(fmt.Sprintf("- Max notional per position: %.0f USDT (= equity %.0f × %.1fx)\n", maxPositionValue, accountEquity, tradeFiPositionValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Max margin usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min order size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
sb.WriteString("## AI guided\n")
|
||||
sb.WriteString(fmt.Sprintf("- Leverage: every open position must use exactly %dx\n", riskControl.AltcoinMaxLeverage))
|
||||
sb.WriteString(fmt.Sprintf("- Risk/reward: ≥1:%.1f\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min confidence to open: ≥%d\n\n", riskControl.MinConfidence))
|
||||
sb.WriteString("# Position Sizing\n\n")
|
||||
sb.WriteString("For every `open_long` or `open_short`, use the full max notional per position.\n")
|
||||
sb.WriteString("- Do not scale position_size_usd down by confidence.\n")
|
||||
sb.WriteString("- Do not open small probe positions.\n")
|
||||
sb.WriteString("- If the setup is not strong enough for full size, output `wait`.\n")
|
||||
sb.WriteString("- Do not use available_balance directly as position_size_usd.\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
func writeVergexOutputFormat(sb *strings.Builder, accountEquity float64, riskControl store.RiskControlConfig, tradeFiPositionValueRatio float64, singleSymbol bool, primarySymbol string, zh bool) {
|
||||
exampleSymbol := "xyz:NVDA"
|
||||
secondSymbol := "xyz:AAPL"
|
||||
if singleSymbol && strings.TrimSpace(primarySymbol) != "" {
|
||||
exampleSymbol = primarySymbol
|
||||
secondSymbol = primarySymbol
|
||||
}
|
||||
positionSize := accountEquity * tradeFiPositionValueRatio
|
||||
leverage := riskControl.AltcoinMaxLeverage
|
||||
if leverage <= 0 {
|
||||
leverage = 1
|
||||
}
|
||||
|
||||
sb.WriteString("# Output Format (Strictly Follow)\n\n")
|
||||
if zh {
|
||||
sb.WriteString("必须使用 XML 标签 <reasoning> 和 <decision> 分隔简明分析和决策 JSON。\n\n")
|
||||
sb.WriteString("方向必须由数据决定:上涨结构确认时可以 `open_long`,下跌结构确认时可以 `open_short`;不要默认只做多或只做空。\n\n")
|
||||
} else {
|
||||
sb.WriteString("Use XML tags <reasoning> and <decision> to separate concise analysis from the decision JSON.\n\n")
|
||||
sb.WriteString("Direction must be data-driven: use `open_long` for confirmed upside structures and `open_short` for confirmed downside structures; never default to long-only or short-only behavior.\n\n")
|
||||
}
|
||||
sb.WriteString("<reasoning>\n")
|
||||
if zh {
|
||||
sb.WriteString("简明说明: Claw402 排名、Signal Lab、热力图、K 线是否一致;如果缺数据或冲突,说明为什么等待。\n")
|
||||
} else {
|
||||
sb.WriteString("Briefly state whether Claw402 ranking, Signal Lab, heatmap and candles agree; if data is missing or conflicting, explain why you wait.\n")
|
||||
}
|
||||
sb.WriteString("</reasoning>\n\n")
|
||||
sb.WriteString("<decision>\n")
|
||||
sb.WriteString("```json\n[\n")
|
||||
if singleSymbol {
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0}\n", exampleSymbol, leverage, positionSize))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_long\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0},\n", exampleSymbol, leverage, positionSize))
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0}\n", secondSymbol, leverage, positionSize))
|
||||
}
|
||||
sb.WriteString("]\n```\n")
|
||||
sb.WriteString("</decision>\n\n")
|
||||
|
||||
if zh {
|
||||
sb.WriteString("## 字段要求\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100,开仓建议 ≥ %d\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- 开仓时必填: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- 所有数值必须是算好的数字,不能写公式。\n")
|
||||
if singleSymbol {
|
||||
sb.WriteString(fmt.Sprintf("- 本策略只交易 `%s`,JSON 的 symbol 必须完全等于它。\n", exampleSymbol))
|
||||
} else {
|
||||
sb.WriteString("- JSON 的 symbol 必须完全来自本轮候选标的或已有持仓;`xyz:` 标的保留前缀,core crypto 标的不要添加 `xyz:` 或 `USDT` 后缀。\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
} else {
|
||||
sb.WriteString("## Field Requirements\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100; recommended ≥ %d to open\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- All numeric values must be calculated numbers, not formulas.\n")
|
||||
if singleSymbol {
|
||||
sb.WriteString(fmt.Sprintf("- This strategy trades only `%s`; JSON symbol must match it exactly.\n", exampleSymbol))
|
||||
} else {
|
||||
sb.WriteString("- JSON symbols must exactly match current candidates or existing positions; keep `xyz:` on XYZ instruments, and do not add `xyz:` or `USDT` to core crypto symbols.\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// buildXYZStockCustomPrompt returns the canonical English directional stock
|
||||
// briefing the agent uses for single-symbol Hyperliquid USDC perpetuals on
|
||||
// the XYZ board. Symbol is inlined for LLM grounding so it never confuses the
|
||||
// trading instrument.
|
||||
func buildXYZStockCustomPrompt(symbol string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Trade ONLY the Hyperliquid USDC perpetual %s (US equity / xyz board).\n\n", symbol))
|
||||
sb.WriteString("Core stance: DIRECTIONAL, SIGNAL-DRIVEN. You may open long or short; never force a trade when Signal Lab, liquidation structure and candles disagree.\n\n")
|
||||
|
||||
sb.WriteString("## Flat-Account Rule\n")
|
||||
sb.WriteString("If `Current Positions` is None / empty, evaluate both directions from scratch.\n")
|
||||
sb.WriteString("- Use `open_long` only when upside continuation or bullish reversal is confirmed.\n")
|
||||
sb.WriteString("- Use `open_short` only when downside continuation or bearish reversal is confirmed.\n")
|
||||
sb.WriteString("- Use `wait` when neither side meets the minimum confidence and risk/reward threshold.\n")
|
||||
sb.WriteString("- Do not raise confidence just to force an order; confidence must reflect the evidence.\n\n")
|
||||
|
||||
sb.WriteString("## Long Entry Conditions\n")
|
||||
sb.WriteString("- Break of the prior session/intraday high on rising volume.\n")
|
||||
sb.WriteString("- Pullback to a clearly held intraday support (prior swing low, VWAP, EMA20/50) with a bullish reaction bar.\n")
|
||||
sb.WriteString("- Sector tape strength (broad US-equity bid, sympathy with peers in the same theme).\n")
|
||||
sb.WriteString("- Confirmed catalyst: earnings beat, guide up, sector rotation, macro tailwind.\n\n")
|
||||
|
||||
sb.WriteString("## Short Entry Conditions\n")
|
||||
sb.WriteString("- Breakdown below intraday support or value area with expanding volume.\n")
|
||||
sb.WriteString("- Failed breakout, lower high, or bearish rejection at resistance.\n")
|
||||
sb.WriteString("- Signal Lab / liquidation structure shows downside fuel, trapped longs, or weak support below.\n")
|
||||
sb.WriteString("- Negative catalyst: earnings miss, guide down, sector weakness, macro headwind.\n\n")
|
||||
|
||||
sb.WriteString("## Risk Guardrails (non-negotiable)\n")
|
||||
sb.WriteString("- Per-trade stop-loss: 1.5-3% from entry. ALWAYS set a numeric `stop_loss`.\n")
|
||||
sb.WriteString("- Take-profit: target at least R/R 2:1; set a numeric `take_profit`.\n")
|
||||
sb.WriteString("- Per-trade notional: <= 25% of account equity (probing 10-15%, full 20-25%).\n")
|
||||
sb.WriteString("- Leverage: 2-3x default, never above 5x. Never go all-in.\n")
|
||||
sb.WriteString("- Do not flip directly from long to short or short to long in the same cycle. Manage or close the open position first.\n\n")
|
||||
|
||||
sb.WriteString("## Position Management\n")
|
||||
sb.WriteString("- Trail stop to breakeven once +1R, take partial profits at +2R if momentum stalls.\n")
|
||||
sb.WriteString("- Cut quickly if price breaks the stop or the catalyst thesis fails.\n")
|
||||
sb.WriteString("- Holding past 45 minutes is fine; flipping in/out every cycle is not.\n\n")
|
||||
|
||||
sb.WriteString("## Discipline\n")
|
||||
sb.WriteString(fmt.Sprintf("- Single-symbol mandate: never rotate into another ticker. The decision JSON `symbol` MUST be exactly \"%s\".\n", symbol))
|
||||
sb.WriteString("- Before every decision: check current price vs prior pivot, volume vs 5m/1h average, and the broader US-equity tape.\n")
|
||||
sb.WriteString("- If positions are open, prioritize managing them over piling on new ones.")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// singleSymbolInfo returns (true, "ARM-USDC") for static-coin strategies that
|
||||
// trade exactly one instrument. Multi-symbol strategies return (false, "").
|
||||
// The flag is used to drop crypto-specific "BTC/ETH vs Altcoin" labeling and
|
||||
// to put the actual trading symbol into the JSON example.
|
||||
func (e *StrategyEngine) singleSymbolInfo() (bool, string) {
|
||||
coinSource := e.config.CoinSource
|
||||
if (coinSource.SourceType == "static" || coinSource.SourceType == "vergex_signal") && len(coinSource.StaticCoins) == 1 {
|
||||
return true, strings.ToUpper(strings.TrimSpace(coinSource.StaticCoins[0]))
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func writeModeVariant(sb *strings.Builder, variant string, zh bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(variant)) {
|
||||
case "aggressive":
|
||||
if zh {
|
||||
sb.WriteString("## 模式: 激进\n- 优先捕捉趋势突破, 置信度 ≥ 70 时可分批建仓\n- 允许更高仓位, 但必须严格止损并说明风险回报比\n\n")
|
||||
} else {
|
||||
sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts; may scale in when confidence ≥ 70\n- Allow larger positions, but must strictly set stop-loss and explain the risk-reward ratio\n\n")
|
||||
}
|
||||
case "conservative":
|
||||
if zh {
|
||||
sb.WriteString("## 模式: 保守\n- 只有当多重信号共振时才开仓\n- 优先保本, 连亏后必须暂停多个周期\n\n")
|
||||
} else {
|
||||
sb.WriteString("## Mode: Conservative\n- Open positions only when multiple signals resonate\n- Prioritize capital preservation; pause for multiple periods after consecutive losses\n\n")
|
||||
}
|
||||
case "scalping":
|
||||
if zh {
|
||||
sb.WriteString("## 模式: 短线\n- 关注短期动量, 利润目标较小但要求迅速行动\n- 价格两根 K 线内未按预期走 → 立即减仓或止损\n\n")
|
||||
} else {
|
||||
sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeHardConstraints(sb *strings.Builder, accountEquity float64, riskControl store.RiskControlConfig, btcEthPosValueRatio, altcoinPosValueRatio float64, singleSymbol bool, primarySymbol string, zh bool) {
|
||||
if zh {
|
||||
sb.WriteString("# 风控硬约束\n\n")
|
||||
sb.WriteString("## 代码强制 (后端校验, 无法绕过):\n")
|
||||
sb.WriteString(fmt.Sprintf("- 最大持仓数: 同时 %d 个标的\n", riskControl.MaxPositions))
|
||||
} else {
|
||||
sb.WriteString("# Hard Constraints (Risk Control)\n\n")
|
||||
sb.WriteString("## CODE ENFORCED (backend validation, cannot be bypassed):\n")
|
||||
sb.WriteString(fmt.Sprintf("- Max Positions: %d instruments simultaneously\n", riskControl.MaxPositions))
|
||||
}
|
||||
|
||||
if singleSymbol {
|
||||
// One symbol — pick the higher of the two configured ratios so the
|
||||
// limit isn't accidentally clamped to the altcoin cap for a stock.
|
||||
ratio := altcoinPosValueRatio
|
||||
if btcEthPosValueRatio > ratio {
|
||||
ratio = btcEthPosValueRatio
|
||||
}
|
||||
maxVal := accountEquity * ratio
|
||||
symLabel := primarySymbol
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- 单仓最大价值 (%s): %.0f USDT (= 权益 %.0f × %.1fx)\n", symLabel, maxVal, accountEquity, ratio))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (%s): max %.0f USDT (= equity %.0f × %.1fx)\n", symLabel, maxVal, accountEquity, ratio))
|
||||
}
|
||||
} else {
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- 单仓最大价值 (山寨币/股票): %.0f USDT (= 权益 %.0f × %.1fx)\n", accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- 单仓最大价值 (BTC/ETH): %.0f USDT (= 权益 %.0f × %.1fx)\n", accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (Altcoin/Stock): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (BTC/ETH): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
|
||||
}
|
||||
}
|
||||
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- 最大保证金占用: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- 最小下单金额: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
sb.WriteString("## AI 建议 (推荐遵循):\n")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Max Margin Usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min Position Size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
sb.WriteString("## AI GUIDED (recommended):\n")
|
||||
}
|
||||
|
||||
if singleSymbol {
|
||||
lev := riskControl.AltcoinMaxLeverage
|
||||
if riskControl.BTCETHMaxLeverage > lev {
|
||||
lev = riskControl.BTCETHMaxLeverage
|
||||
}
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- 交易杠杆 (%s): 最高 %dx\n", primarySymbol, lev))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage (%s): max %dx\n", primarySymbol, lev))
|
||||
}
|
||||
} else {
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- 交易杠杆: 山寨币/股票 最高 %dx | BTC/ETH 最高 %dx\n", riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage: Altcoin/Stock max %dx | BTC/ETH max %dx\n", riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
|
||||
}
|
||||
}
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- 风险回报比: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- 最小置信度: ≥%d 才开仓\n\n", riskControl.MinConfidence))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Risk-Reward Ratio: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min Confidence: ≥%d to open position\n\n", riskControl.MinConfidence))
|
||||
}
|
||||
|
||||
// Position sizing guidance
|
||||
exampleRatio := btcEthPosValueRatio
|
||||
if singleSymbol {
|
||||
exampleRatio = altcoinPosValueRatio
|
||||
if btcEthPosValueRatio > exampleRatio {
|
||||
exampleRatio = btcEthPosValueRatio
|
||||
}
|
||||
}
|
||||
if zh {
|
||||
sb.WriteString("## 仓位大小指引\n")
|
||||
sb.WriteString("根据置信度和上面的单仓最大价值算出 `position_size_usd`:\n")
|
||||
sb.WriteString("- 高置信 (≥85): 用最大价值的 80-100%%\n")
|
||||
sb.WriteString("- 中置信 (70-84): 用最大价值的 50-80%%\n")
|
||||
sb.WriteString("- 低置信 (60-69): 用最大价值的 30-50%%\n")
|
||||
sb.WriteString(fmt.Sprintf("- 示例: 权益 %.0f × %.1fx = 最大 %.0f USDT\n", accountEquity, exampleRatio, accountEquity*exampleRatio))
|
||||
sb.WriteString("- **不要**直接拿 available_balance 当 position_size_usd, 用上面的单仓最大价值!\n\n")
|
||||
} else {
|
||||
sb.WriteString("## Position Sizing Guidance\n")
|
||||
sb.WriteString("Calculate `position_size_usd` from your confidence and the Position Value Limits above:\n")
|
||||
sb.WriteString("- High confidence (≥85): use 80-100%% of the position value limit\n")
|
||||
sb.WriteString("- Medium confidence (70-84): use 50-80%% of the position value limit\n")
|
||||
sb.WriteString("- Low confidence (60-69): use 30-50%% of the position value limit\n")
|
||||
sb.WriteString(fmt.Sprintf("- Example: equity %.0f × %.1fx = max %.0f USDT\n", accountEquity, exampleRatio, accountEquity*exampleRatio))
|
||||
sb.WriteString("- **DO NOT** just use available_balance as position_size_usd. Use the Position Value Limit!\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
func writeOutputFormat(sb *strings.Builder, accountEquity, btcEthPosValueRatio float64, riskControl store.RiskControlConfig, singleSymbol bool, primarySymbol string, zh bool) {
|
||||
// Output format schema MUST stay English/structural; parser depends on it.
|
||||
sb.WriteString("# Output Format (Strictly Follow)\n\n")
|
||||
if zh {
|
||||
sb.WriteString("**必须使用 XML 标签 <reasoning> 和 <decision> 分隔思维链和决策 JSON, 避免解析错误**\n\n")
|
||||
} else {
|
||||
sb.WriteString("**Must use XML tags <reasoning> and <decision> to separate chain of thought and decision JSON, avoiding parsing errors**\n\n")
|
||||
}
|
||||
sb.WriteString("## Format Requirements\n\n")
|
||||
sb.WriteString("<reasoning>\n")
|
||||
if zh {
|
||||
sb.WriteString("你的思维链分析...\n- 简明分析你的思考过程\n")
|
||||
} else {
|
||||
sb.WriteString("Your chain of thought analysis...\n- Briefly analyze your thinking process\n")
|
||||
}
|
||||
sb.WriteString("</reasoning>\n\n")
|
||||
sb.WriteString("<decision>\n")
|
||||
if zh {
|
||||
sb.WriteString("步骤 2: JSON 决策数组\n\n")
|
||||
} else {
|
||||
sb.WriteString("Step 2: JSON decision array\n\n")
|
||||
}
|
||||
sb.WriteString("```json\n[\n")
|
||||
|
||||
// Build a JSON example using the actual trading symbol when the strategy
|
||||
// is single-symbol. Falls back to the legacy BTC/ETH two-line example
|
||||
// only for multi-symbol strategies that genuinely have BTC/ETH on tap.
|
||||
if singleSymbol {
|
||||
lev := riskControl.AltcoinMaxLeverage
|
||||
if riskControl.BTCETHMaxLeverage > lev {
|
||||
lev = riskControl.BTCETHMaxLeverage
|
||||
}
|
||||
ratio := btcEthPosValueRatio // already chosen as the larger above when single-symbol
|
||||
size := accountEquity * ratio
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"open_long\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 0, \"take_profit\": 0, \"confidence\": 85, \"risk_usd\": 0},\n", primarySymbol, lev, size))
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"%s\", \"action\": \"wait\"}\n", primarySymbol))
|
||||
} else {
|
||||
examplePositionSize := accountEquity * btcEthPosValueRatio
|
||||
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n",
|
||||
riskControl.BTCETHMaxLeverage, examplePositionSize))
|
||||
sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n")
|
||||
}
|
||||
sb.WriteString("]\n```\n")
|
||||
sb.WriteString("</decision>\n\n")
|
||||
|
||||
if zh {
|
||||
sb.WriteString("## 字段说明\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (开仓建议 ≥ %d)\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- 开仓时必填: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- **重要**: 所有数值必须是算好的数字, 不能是公式/表达式 (例如写 `27.76`, 不要写 `3000 * 0.01`)\n")
|
||||
if singleSymbol {
|
||||
sb.WriteString(fmt.Sprintf("- **本策略只交易 %s**, JSON 中的 `symbol` 必须**完全等于** `%s`, 不要写成 `%s` 去掉后缀或加 USDT 的变体。\n", primarySymbol, primarySymbol, primarySymbol))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
} else {
|
||||
sb.WriteString("## Field Description\n\n")
|
||||
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n")
|
||||
sb.WriteString(fmt.Sprintf("- `confidence`: 0-100 (opening recommended ≥ %d)\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- Required when opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- **IMPORTANT**: all numeric values must be calculated numbers, NOT formulas/expressions (e.g. use `27.76`, not `3000 * 0.01`)\n")
|
||||
if singleSymbol {
|
||||
sb.WriteString(fmt.Sprintf("- **This strategy trades only %s.** The JSON `symbol` MUST match `%s` exactly — do not add USDT/USDC suffix variants.\n", primarySymbol, primarySymbol))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder, zh bool) {
|
||||
indicators := e.config.Indicators
|
||||
kline := indicators.Klines
|
||||
|
||||
sb.WriteString(fmt.Sprintf("- %s price series", kline.PrimaryTimeframe))
|
||||
if kline.EnableMultiTimeframe {
|
||||
sb.WriteString(fmt.Sprintf(" + %s K-line series\n", kline.LongerTimeframe))
|
||||
label := func(en, zhStr string) string {
|
||||
if zh {
|
||||
return zhStr
|
||||
}
|
||||
return en
|
||||
}
|
||||
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- %s 价格序列", kline.PrimaryTimeframe))
|
||||
if kline.EnableMultiTimeframe {
|
||||
sb.WriteString(fmt.Sprintf(" + %s K 线序列\n", kline.LongerTimeframe))
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(fmt.Sprintf("- %s price series", kline.PrimaryTimeframe))
|
||||
if kline.EnableMultiTimeframe {
|
||||
sb.WriteString(fmt.Sprintf(" + %s K-line series\n", kline.LongerTimeframe))
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if indicators.EnableEMA {
|
||||
sb.WriteString("- EMA indicators")
|
||||
sb.WriteString("- " + label("EMA indicators", "EMA 指标"))
|
||||
if len(indicators.EMAPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.EMAPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "周期"), indicators.EMAPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableMACD {
|
||||
sb.WriteString("- MACD indicators\n")
|
||||
sb.WriteString("- " + label("MACD indicators", "MACD 指标") + "\n")
|
||||
}
|
||||
|
||||
if indicators.EnableRSI {
|
||||
sb.WriteString("- RSI indicators")
|
||||
sb.WriteString("- " + label("RSI indicators", "RSI 指标"))
|
||||
if len(indicators.RSIPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.RSIPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "周期"), indicators.RSIPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableATR {
|
||||
sb.WriteString("- ATR indicators")
|
||||
sb.WriteString("- " + label("ATR indicators", "ATR 指标"))
|
||||
if len(indicators.ATRPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.ATRPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "周期"), indicators.ATRPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableBOLL {
|
||||
sb.WriteString("- Bollinger Bands (BOLL) - Upper/Middle/Lower bands")
|
||||
sb.WriteString("- " + label("Bollinger Bands (BOLL) - Upper/Middle/Lower bands", "布林带 (BOLL) - 上/中/下轨"))
|
||||
if len(indicators.BOLLPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (periods: %v)", indicators.BOLLPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "周期"), indicators.BOLLPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if indicators.EnableVolume {
|
||||
sb.WriteString("- Volume data\n")
|
||||
sb.WriteString("- " + label("Volume data", "成交量数据") + "\n")
|
||||
}
|
||||
|
||||
if indicators.EnableOI {
|
||||
sb.WriteString("- Open Interest (OI) data\n")
|
||||
sb.WriteString("- " + label("Open Interest (OI) data", "持仓量 (OI) 数据") + "\n")
|
||||
}
|
||||
|
||||
if indicators.EnableFundingRate {
|
||||
sb.WriteString("- Funding rate\n")
|
||||
sb.WriteString("- " + label("Funding rate", "资金费率") + "\n")
|
||||
}
|
||||
|
||||
if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseAI500 || e.config.CoinSource.UseOITop {
|
||||
sb.WriteString("- AI500 / OI_Top filter tags (if available)\n")
|
||||
sb.WriteString("- " + label("AI500 / OI_Top filter tags (if available)", "AI500 / OI_Top 过滤标记 (如有)") + "\n")
|
||||
}
|
||||
|
||||
if indicators.EnableQuantData {
|
||||
sb.WriteString("- Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)\n")
|
||||
sb.WriteString("- " + label("Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)", "量化数据 (机构/散户资金流, 持仓变化, 多周期价格变动)") + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,6 +853,11 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
sb.WriteString(e.formatQuantData(quantData))
|
||||
}
|
||||
}
|
||||
if ctx.VergexDataMap != nil {
|
||||
if vergexData, hasVergex := ctx.VergexDataMap[coin.Symbol]; hasVergex {
|
||||
sb.WriteString(e.formatVergexData(vergexData))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
@@ -394,7 +884,7 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
}
|
||||
|
||||
sb.WriteString("---\n\n")
|
||||
sb.WriteString("Now please analyze and output your decision (Chain of Thought + JSON)\n")
|
||||
sb.WriteString("Now please analyze briefly and output the decision JSON.\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -433,6 +923,11 @@ func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Co
|
||||
sb.WriteString(e.formatQuantData(quantData))
|
||||
}
|
||||
}
|
||||
if ctx.VergexDataMap != nil {
|
||||
if vergexData, hasVergex := ctx.VergexDataMap[pos.Symbol]; hasVergex {
|
||||
sb.WriteString(e.formatVergexData(vergexData))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
@@ -491,11 +986,26 @@ func (e *StrategyEngine) formatCoinSourceTag(sources []string) string {
|
||||
return " (Hyperliquid All)"
|
||||
case "hyper_main":
|
||||
return " (Hyperliquid Top20)"
|
||||
case "vergex_signal":
|
||||
return " (Vergex Signal)"
|
||||
}
|
||||
if strings.HasPrefix(sources[0], "hyper_rank") {
|
||||
return " (Hyperliquid Dynamic Rank)"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *StrategyEngine) formatVergexData(data *vergex.MarketAnalysis) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("\nVergex Claw402 Signals:\n")
|
||||
sb.WriteString(vergex.FormatAnalysisForAI(data))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Market Data Formatting
|
||||
// ============================================================================
|
||||
|
||||
124
kernel/engine_prompt_test.go
Normal file
124
kernel/engine_prompt_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
func TestBuildSystemPromptUsesVergexClaw402Prompt(t *testing.T) {
|
||||
cfg := store.GetDefaultStrategyConfig("zh")
|
||||
cfg.CoinSource.SourceType = "vergex_signal"
|
||||
cfg.CoinSource.VergexLimit = 5
|
||||
cfg.PromptSections.RoleDefinition = "# 你是一个专业的 Hyperliquid USDC 多资产交易AI"
|
||||
cfg.CustomPrompt = "只做多,不做空。"
|
||||
|
||||
engine := NewStrategyEngine(&cfg)
|
||||
prompt := engine.BuildSystemPrompt(30, "balanced")
|
||||
|
||||
if !strings.Contains(prompt, "NOFX Claw402 auto-trader") {
|
||||
t.Fatalf("prompt did not use the Claw402/Vergex TradeFi role:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Claw402.ai Signal Ranking") || !strings.Contains(prompt, "Signal Lab") || !strings.Contains(prompt, "Cost/Liquidation Heatmap") {
|
||||
t.Fatalf("prompt is missing Claw402/Vergex detail data guidance:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "open_short") {
|
||||
t.Fatalf("prompt should explicitly allow short entries:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Direction must be data-driven") {
|
||||
t.Fatalf("prompt should explain that direction is data-driven, not long-only:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "every open position must use exactly 10x") {
|
||||
t.Fatalf("prompt should force 10x leverage for Claw402 opens:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "use the full max notional per position") {
|
||||
t.Fatalf("prompt should force full-size Claw402 opens:\n%s", prompt)
|
||||
}
|
||||
if containsCJK(prompt) {
|
||||
t.Fatalf("system prompt must be English-only, got CJK text:\n%s", prompt)
|
||||
}
|
||||
legacyPhrases := []string{
|
||||
"Hyperliquid USDC 多资产交易AI",
|
||||
"只做多",
|
||||
"山寨币",
|
||||
"BTC/ETH",
|
||||
"LONG-ONLY",
|
||||
"Do not short",
|
||||
"MUST open a long",
|
||||
}
|
||||
for _, phrase := range legacyPhrases {
|
||||
if strings.Contains(prompt, phrase) {
|
||||
t.Fatalf("prompt still contains legacy phrase %q:\n%s", phrase, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptFallsBackToEnglishWhenConfiguredLanguageIsChinese(t *testing.T) {
|
||||
cfg := store.GetDefaultStrategyConfig("zh")
|
||||
cfg.CoinSource.SourceType = "static"
|
||||
cfg.CoinSource.StaticCoins = []string{"BTCUSDT", "ETHUSDT"}
|
||||
cfg.CoinSource.VergexLimit = 0
|
||||
cfg.CoinSource.VergexMarketType = ""
|
||||
cfg.CoinSource.VergexChain = ""
|
||||
cfg.PromptSections.RoleDefinition = "# 你是中文系统提示词"
|
||||
cfg.PromptSections.TradingFrequency = "# 高频交易\n每分钟交易。"
|
||||
cfg.PromptSections.EntryStandards = "# 入场\n随便开仓。"
|
||||
cfg.PromptSections.DecisionProcess = "# 决策\n直接输出。"
|
||||
cfg.CustomPrompt = "中文偏好不应进入系统提示词。"
|
||||
|
||||
engine := NewStrategyEngine(&cfg)
|
||||
prompt := engine.BuildSystemPrompt(30, "balanced")
|
||||
|
||||
required := []string{
|
||||
"Data Dictionary & Trading Rules",
|
||||
"You are a professional Hyperliquid USDC multi-asset trading AI",
|
||||
"Trading Frequency Awareness",
|
||||
"Entry Standards",
|
||||
"Decision Process",
|
||||
}
|
||||
for _, phrase := range required {
|
||||
if !strings.Contains(prompt, phrase) {
|
||||
t.Fatalf("English fallback prompt missing %q:\n%s", phrase, prompt)
|
||||
}
|
||||
}
|
||||
if containsCJK(prompt) {
|
||||
t.Fatalf("system prompt must be English-only, got CJK text:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptDoesNotForceLongOnlyForSingleXYZ(t *testing.T) {
|
||||
prompt := buildXYZStockCustomPrompt("XYZ:INTC")
|
||||
|
||||
required := []string{
|
||||
"DIRECTIONAL, SIGNAL-DRIVEN",
|
||||
"You may open long or short",
|
||||
"open_short",
|
||||
}
|
||||
for _, phrase := range required {
|
||||
if !strings.Contains(prompt, phrase) {
|
||||
t.Fatalf("single XYZ prompt missing %q:\n%s", phrase, prompt)
|
||||
}
|
||||
}
|
||||
|
||||
forbidden := []string{
|
||||
"LONG-ONLY",
|
||||
"Do not short",
|
||||
"MUST open a long",
|
||||
"Probing > waiting",
|
||||
}
|
||||
for _, phrase := range forbidden {
|
||||
if strings.Contains(prompt, phrase) {
|
||||
t.Fatalf("single XYZ prompt still contains forced-long phrase %q:\n%s", phrase, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsCJK(text string) bool {
|
||||
for _, r := range text {
|
||||
if r >= 0x4E00 && r <= 0x9FFF {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
107
kernel/engine_vergex_test.go
Normal file
107
kernel/engine_vergex_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"nofx/provider/vergex"
|
||||
)
|
||||
|
||||
func TestVergexDetailQueryCandidatesUseHIP3MarketAndMainnetChain(t *testing.T) {
|
||||
candidates := vergexDetailQueryCandidates(vergex.Query{
|
||||
MarketType: vergex.DefaultMarketType,
|
||||
Symbol: "xyz:INTC",
|
||||
Chain: vergex.DefaultChain,
|
||||
Category: "stock",
|
||||
})
|
||||
|
||||
if len(candidates) == 0 {
|
||||
t.Fatal("expected detail query candidates")
|
||||
}
|
||||
if candidates[0].MarketType != "hip3_perp" || candidates[0].Chain != "mainnet" {
|
||||
t.Fatalf("first candidate = %+v, want hip3_perp/mainnet", candidates[0])
|
||||
}
|
||||
|
||||
if !hasVergexDetailCandidate(candidates, "hip3_perp", "") {
|
||||
t.Fatalf("expected hip3_perp/default-chain fallback in %+v", candidates)
|
||||
}
|
||||
if hasVergexDetailCandidate(candidates, "stock", "mainnet") {
|
||||
t.Fatalf("did not expect stock marketType fallback for Vergex detail endpoint: %+v", candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVergexDetailSymbolForLookupKeepsCoreCryptoBaseSymbols(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
marketType string
|
||||
symbol string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "core crypto from all board",
|
||||
marketType: "all",
|
||||
symbol: "AAVE",
|
||||
want: "AAVE",
|
||||
},
|
||||
{
|
||||
name: "core crypto with usdt suffix",
|
||||
marketType: "all",
|
||||
symbol: "HYPEUSDT",
|
||||
want: "HYPE",
|
||||
},
|
||||
{
|
||||
name: "xyz stock keeps xyz prefix",
|
||||
marketType: "all",
|
||||
symbol: "xyz:INTC",
|
||||
want: "xyz:INTC",
|
||||
},
|
||||
{
|
||||
name: "hip3 symbol gains xyz prefix",
|
||||
marketType: vergex.DefaultMarketType,
|
||||
symbol: "SNDK",
|
||||
want: "xyz:SNDK",
|
||||
},
|
||||
{
|
||||
name: "core market strips suffix",
|
||||
marketType: "core_perp",
|
||||
symbol: "LITUSDT",
|
||||
want: "LIT",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := vergexDetailSymbolForLookup(tc.marketType, tc.symbol); got != tc.want {
|
||||
t.Fatalf("vergexDetailSymbolForLookup(%q, %q) = %q, want %q", tc.marketType, tc.symbol, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVergexDetailQueryCandidatesPreferMarketTypeBySymbolWhenSourceIsAll(t *testing.T) {
|
||||
cryptoCandidates := vergexDetailQueryCandidates(vergex.Query{
|
||||
MarketType: "all",
|
||||
Symbol: "AAVE",
|
||||
Chain: "mainnet",
|
||||
})
|
||||
if len(cryptoCandidates) == 0 || cryptoCandidates[0].MarketType != "core_perp" {
|
||||
t.Fatalf("crypto candidates should prefer core_perp first: %+v", cryptoCandidates)
|
||||
}
|
||||
|
||||
xyzCandidates := vergexDetailQueryCandidates(vergex.Query{
|
||||
MarketType: "all",
|
||||
Symbol: "xyz:SNDK",
|
||||
Chain: "mainnet",
|
||||
})
|
||||
if len(xyzCandidates) == 0 || xyzCandidates[0].MarketType != vergex.DefaultMarketType {
|
||||
t.Fatalf("xyz candidates should prefer hip3_perp first: %+v", xyzCandidates)
|
||||
}
|
||||
}
|
||||
|
||||
func hasVergexDetailCandidate(candidates []vergex.Query, marketType, chain string) bool {
|
||||
for _, candidate := range candidates {
|
||||
if candidate.MarketType == marketType && candidate.Chain == chain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
15
kernel/hyperliquid_rank_test.go
Normal file
15
kernel/hyperliquid_rank_test.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package kernel
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClampHyperRankLimit(t *testing.T) {
|
||||
if got := clampHyperRankLimit(0); got != 5 {
|
||||
t.Fatalf("clamp 0 = %d, want 5", got)
|
||||
}
|
||||
if got := clampHyperRankLimit(99); got != 10 {
|
||||
t.Fatalf("clamp 99 = %d, want 10", got)
|
||||
}
|
||||
if got := clampHyperRankLimit(3); got != 3 {
|
||||
t.Fatalf("clamp 3 = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,20 @@ func TestLeverageFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaw402XyzAllowsFullTenXNotional(t *testing.T) {
|
||||
decision := Decision{
|
||||
Symbol: "xyz:SP500",
|
||||
Action: "open_long",
|
||||
Leverage: 10,
|
||||
PositionSizeUSD: 306.8,
|
||||
StopLoss: 95,
|
||||
TakeProfit: 120,
|
||||
}
|
||||
|
||||
if err := validateDecision(&decision, 30.68, 10, 10, 10.0, 10.0); err != nil {
|
||||
t.Fatalf("xyz TradeFi Claw402 full 10x notional should pass validation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// contains checks if string contains substring (helper function)
|
||||
func contains(s, substr string) bool {
|
||||
|
||||
39
main.go
39
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")
|
||||
|
||||
@@ -119,10 +127,10 @@ func main() {
|
||||
status = "✅ Running"
|
||||
}
|
||||
idShort := t.ID
|
||||
if len(idShort) > 8 {
|
||||
idShort = idShort[:8]
|
||||
}
|
||||
logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s",
|
||||
if len(idShort) > 8 {
|
||||
idShort = idShort[:8]
|
||||
}
|
||||
logger.Infof(" • %s [%s] %s - AI Model: %s, Exchange: %s",
|
||||
t.Name, idShort, status, t.AIModelID, t.ExchangeID)
|
||||
}
|
||||
}
|
||||
@@ -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,34 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
|
||||
// Load strategy config (must have strategy)
|
||||
var strategyConfig *store.StrategyConfig
|
||||
strategyConfigRaw := ""
|
||||
if traderCfg.StrategyID != "" {
|
||||
strategy, err := st.Strategy().Get(traderCfg.UserID, traderCfg.StrategyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load strategy %s for trader %s: %w", traderCfg.StrategyID, traderCfg.Name, err)
|
||||
}
|
||||
strategyConfigRaw = strategy.Config
|
||||
// Parse JSON config
|
||||
strategyConfig, err = strategy.ParseConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse strategy config for trader %s: %w", traderCfg.Name, err)
|
||||
}
|
||||
strategyConfig.ClampLimits()
|
||||
logger.Infof("✓ Trader %s loaded strategy config: %s", traderCfg.Name, strategy.Name)
|
||||
ensureHyperliquidNativeStrategy(traderCfg.Name, exchangeCfg.ExchangeType, strategyConfig)
|
||||
} else {
|
||||
return fmt.Errorf("trader %s has no strategy configured", traderCfg.Name)
|
||||
}
|
||||
|
||||
if exchangeCfg.ExchangeType == "hyperliquid" && !exchangeCfg.HyperliquidBuilderApproved {
|
||||
return fmt.Errorf("Hyperliquid trading authorization is incomplete for exchange %s; reconnect Hyperliquid wallet and complete trading authorization before starting trader %s", exchangeCfg.AccountName, traderCfg.Name)
|
||||
}
|
||||
|
||||
// Build AutoTraderConfig (ai500APIURL/oiTopAPIURL obtained from strategy config, used in StrategyEngine)
|
||||
traderConfig := trader.AutoTraderConfig{
|
||||
ID: traderCfg.ID,
|
||||
Name: traderCfg.Name,
|
||||
StrategyID: traderCfg.StrategyID,
|
||||
AIModel: aiModelCfg.Provider,
|
||||
Exchange: exchangeCfg.ExchangeType, // Exchange type: binance/bybit/okx/etc
|
||||
ExchangeID: exchangeCfg.ID, // Exchange account UUID (for multi-account)
|
||||
@@ -645,6 +690,7 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
IsCrossMargin: traderCfg.IsCrossMargin,
|
||||
ShowInCompetition: traderCfg.ShowInCompetition,
|
||||
StrategyConfig: strategyConfig,
|
||||
StrategyConfigRaw: strategyConfigRaw,
|
||||
}
|
||||
|
||||
logger.Infof("📊 Loading trader %s: ScanIntervalMinutes=%d (from DB), ScanInterval=%v",
|
||||
@@ -703,6 +749,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 +773,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"
|
||||
@@ -229,7 +230,7 @@ func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe stri
|
||||
currentRSI7 := calculateRSI(primaryKlines, 7)
|
||||
|
||||
// Calculate price changes
|
||||
priceChange1h := calculatePriceChangeByBars(primaryKlines, primaryTimeframe, 60) // 1 hour
|
||||
priceChange1h := calculatePriceChangeByBars(primaryKlines, primaryTimeframe, 60) // 1 hour
|
||||
priceChange4h := calculatePriceChangeByBars(primaryKlines, primaryTimeframe, 240) // 4 hours
|
||||
|
||||
// Get OI data
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
113
mcp/client.go
113
mcp/client.go
@@ -32,10 +32,13 @@ var (
|
||||
"no such host",
|
||||
"stream error", // HTTP/2 stream error
|
||||
"INTERNAL_ERROR", // Server internal error
|
||||
"status 502", // Bad Gateway
|
||||
"status 503", // Service Unavailable
|
||||
"status 520", // Cloudflare origin error
|
||||
"status 524", // Cloudflare timeout
|
||||
"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
|
||||
"status 524", // Cloudflare timeout
|
||||
}
|
||||
|
||||
// TokenUsageCallback is called after each AI request with token usage info
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,8 +278,9 @@ func (client *Client) ParseMCPResponseFull(body []byte) (*LLMResponse, error) {
|
||||
var result struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
@@ -305,8 +311,9 @@ func (client *Client) ParseMCPResponseFull(body []byte) (*LLMResponse, error) {
|
||||
|
||||
msg := result.Choices[0].Message
|
||||
return &LLMResponse{
|
||||
Content: msg.Content,
|
||||
ToolCalls: msg.ToolCalls,
|
||||
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",
|
||||
@@ -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,13 +35,102 @@ 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).
|
||||
type X402v2PaymentRequired struct {
|
||||
X402Version int `json:"x402Version"`
|
||||
X402Version int `json:"x402Version"`
|
||||
Accepts []X402AcceptOption `json:"accepts"`
|
||||
Resource *X402Resource `json:"resource"`
|
||||
Resource *X402Resource `json:"resource"`
|
||||
}
|
||||
|
||||
// X402AcceptOption is a payment option from the x402 v2 header.
|
||||
@@ -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,25 +350,17 @@ 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))
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 402 — extract payment header and sign
|
||||
@@ -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,13 +1,16 @@
|
||||
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)
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// ToolCall is a single function call requested by the LLM.
|
||||
@@ -27,8 +30,9 @@ type ToolCallFunction struct {
|
||||
// text reply (Content) and any structured tool calls (ToolCalls).
|
||||
// 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)
|
||||
ToolCalls []ToolCall // Structured tool invocations
|
||||
Content string // Plain-text reply (final answer)
|
||||
ReasoningContent string // Thinking-model reasoning content
|
||||
ToolCalls []ToolCall // Structured tool invocations
|
||||
}
|
||||
|
||||
// Tool represents a tool/function that AI can call
|
||||
@@ -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")
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -17,19 +19,43 @@ 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
|
||||
Symbol string `json:"symbol"`
|
||||
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
|
||||
type CoinProvider struct {
|
||||
mu sync.RWMutex
|
||||
allCoins []CoinInfo
|
||||
mainCoins []CoinInfo
|
||||
lastUpdated time.Time
|
||||
httpClient *http.Client
|
||||
mu sync.RWMutex
|
||||
allCoins []CoinInfo
|
||||
mainCoins []CoinInfo
|
||||
lastUpdated time.Time
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -50,76 +76,155 @@ func GetProvider() *CoinProvider {
|
||||
// metaResponse represents the response from Hyperliquid meta endpoint
|
||||
type metaResponse struct {
|
||||
Universe []struct {
|
||||
Name string `json:"name"`
|
||||
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"}`)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", hyperliquidInfoURL,
|
||||
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,
|
||||
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()
|
||||
@@ -134,7 +239,7 @@ func (p *CoinProvider) fetchCoins(ctx context.Context) error {
|
||||
p.lastUpdated = time.Now()
|
||||
|
||||
logger.Infof("✅ Hyperliquid coin list updated: %d total coins, top 20 by volume cached", len(coins))
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -195,7 +300,7 @@ func GetAllCoinSymbols(ctx context.Context) ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
symbols := make([]string, len(coins))
|
||||
for i, c := range coins {
|
||||
symbols[i] = c.Symbol
|
||||
@@ -209,7 +314,7 @@ func GetMainCoinSymbols(ctx context.Context, limit int) ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
symbols := make([]string, len(coins))
|
||||
for i, c := range coins {
|
||||
symbols[i] = c.Symbol
|
||||
|
||||
@@ -18,16 +18,16 @@ const (
|
||||
|
||||
// Candle represents a single OHLCV candle from Hyperliquid
|
||||
type Candle struct {
|
||||
OpenTime int64 `json:"t"` // Open time in milliseconds
|
||||
CloseTime int64 `json:"T"` // Close time in milliseconds
|
||||
Symbol string `json:"s"` // Coin symbol
|
||||
Interval string `json:"i"` // Interval
|
||||
Open string `json:"o"` // Open price
|
||||
High string `json:"h"` // High price
|
||||
Low string `json:"l"` // Low price
|
||||
Close string `json:"c"` // Close price
|
||||
Volume string `json:"v"` // Volume in base unit
|
||||
TradeCount int `json:"n"` // Number of trades
|
||||
OpenTime int64 `json:"t"` // Open time in milliseconds
|
||||
CloseTime int64 `json:"T"` // Close time in milliseconds
|
||||
Symbol string `json:"s"` // Coin symbol
|
||||
Interval string `json:"i"` // Interval
|
||||
Open string `json:"o"` // Open price
|
||||
High string `json:"h"` // High price
|
||||
Low string `json:"l"` // Low price
|
||||
Close string `json:"c"` // Close price
|
||||
Volume string `json:"v"` // Volume in base unit
|
||||
TradeCount int `json:"n"` // Number of trades
|
||||
}
|
||||
|
||||
// CandleRequest represents the request for candleSnapshot
|
||||
@@ -230,21 +230,117 @@ type Meta struct {
|
||||
|
||||
// AssetInfo represents information about a single asset
|
||||
type AssetInfo struct {
|
||||
Name string `json:"name"`
|
||||
SzDecimals int `json:"szDecimals"`
|
||||
MaxLeverage int `json:"maxLeverage"`
|
||||
Name string `json:"name"`
|
||||
SzDecimals int `json:"szDecimals"`
|
||||
MaxLeverage int `json:"maxLeverage"`
|
||||
}
|
||||
|
||||
// NormalizeCoin normalizes coin name for Hyperliquid API
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
960
provider/vergex/client.go
Normal file
960
provider/vergex/client.go
Normal file
@@ -0,0 +1,960 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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,18 +22,20 @@ 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,
|
||||
"gpt-5-mini": 0.005,
|
||||
"claude-opus": 0.12,
|
||||
"qwen-max": 0.01,
|
||||
"qwen-plus": 0.005,
|
||||
"qwen-turbo": 0.002,
|
||||
"qwen-flash": 0.002,
|
||||
"grok-4.1": 0.06,
|
||||
"gemini-3.1-pro": 0.03,
|
||||
"kimi-k2.5": 0.008,
|
||||
"claude-opus": 0.12,
|
||||
"qwen-max": 0.01,
|
||||
"qwen-plus": 0.005,
|
||||
"qwen-turbo": 0.002,
|
||||
"qwen-flash": 0.002,
|
||||
"grok-4.1": 0.06,
|
||||
"gemini-3.1-pro": 0.03,
|
||||
"kimi-k2.5": 0.008,
|
||||
}
|
||||
|
||||
// GetModelPrice returns the price per call for a given model
|
||||
@@ -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)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"nofx/crypto"
|
||||
"nofx/logger"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,16 +19,16 @@ type AIModelStore struct {
|
||||
|
||||
// AIModel AI model configuration
|
||||
type AIModel struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Provider string `gorm:"not null" json:"provider"`
|
||||
Enabled bool `gorm:"default:false" json:"enabled"`
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Provider string `gorm:"not null" json:"provider"`
|
||||
Enabled bool `gorm:"default:false" json:"enabled"`
|
||||
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
|
||||
CustomAPIURL string `gorm:"column:custom_api_url;default:''" json:"customApiUrl"`
|
||||
CustomModelName string `gorm:"column:custom_model_name;default:''" json:"customModelName"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CustomAPIURL string `gorm:"column:custom_api_url;default:''" json:"customApiUrl"`
|
||||
CustomModelName string `gorm:"column:custom_model_name;default:''" json:"customModelName"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (AIModel) TableName() string { return "ai_models" }
|
||||
@@ -131,7 +132,7 @@ func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) {
|
||||
if userID == "" {
|
||||
userID = "default"
|
||||
}
|
||||
model, err := s.firstEnabled(userID)
|
||||
model, err := s.firstEnabledUsable(userID)
|
||||
if err == nil {
|
||||
return model, nil
|
||||
}
|
||||
@@ -139,38 +140,70 @@ func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) {
|
||||
return nil, err
|
||||
}
|
||||
if userID != "default" {
|
||||
return s.firstEnabled("default")
|
||||
return s.firstEnabledUsable("default")
|
||||
}
|
||||
return nil, fmt.Errorf("please configure an available AI model in the system first")
|
||||
}
|
||||
|
||||
func (s *AIModelStore) firstEnabled(userID string) (*AIModel, error) {
|
||||
var model AIModel
|
||||
err := s.db.Where("user_id = ? AND enabled = ?", userID, true).
|
||||
func (s *AIModelStore) firstEnabledUsable(userID string) (*AIModel, error) {
|
||||
var models []AIModel
|
||||
err := s.db.Where("user_id = ? AND enabled = ? AND api_key != ''", userID, true).
|
||||
Order("updated_at DESC, id ASC").
|
||||
First(&model).Error
|
||||
Find(&models).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model, nil
|
||||
for i := range models {
|
||||
if hasUsableAPIKey(models[i]) {
|
||||
return &models[i], nil
|
||||
}
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
// GetAnyEnabled returns the first enabled AI model across all users.
|
||||
// Used by single-user features (e.g. Telegram bot) that need any working LLM client.
|
||||
func (s *AIModelStore) GetAnyEnabled() (*AIModel, error) {
|
||||
var model AIModel
|
||||
err := s.db.Where("enabled = ? AND api_key != ''", true).
|
||||
var models []AIModel
|
||||
err := s.db.Where("enabled = ?", true).
|
||||
Order("updated_at DESC, id ASC").
|
||||
First(&model).Error
|
||||
Find(&models).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &model, nil
|
||||
for i := range models {
|
||||
if hasUsableAPIKey(models[i]) {
|
||||
return &models[i], nil
|
||||
}
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
func hasUsableAPIKey(model AIModel) bool {
|
||||
if strings.TrimSpace(string(model.APIKey)) != "" {
|
||||
return true
|
||||
}
|
||||
envKeyByProvider := map[string]string{
|
||||
"deepseek": "DEEPSEEK_API_KEY",
|
||||
"openai": "OPENAI_API_KEY",
|
||||
"claude": "ANTHROPIC_API_KEY",
|
||||
"gemini": "GEMINI_API_KEY",
|
||||
"grok": "XAI_API_KEY",
|
||||
"kimi": "MOONSHOT_API_KEY",
|
||||
"minimax": "MINIMAX_API_KEY",
|
||||
"qwen": "DASHSCOPE_API_KEY",
|
||||
}
|
||||
envKey := envKeyByProvider[strings.ToLower(strings.TrimSpace(model.Provider))]
|
||||
return envKey != "" && strings.TrimSpace(os.Getenv(envKey)) != ""
|
||||
}
|
||||
|
||||
// Update updates AI model, creates if not exists
|
||||
// IMPORTANT: If apiKey is empty string, the existing API key will be preserved (not overwritten)
|
||||
func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error {
|
||||
return s.UpdateWithName(userID, id, "", enabled, apiKey, customAPIURL, customModelName)
|
||||
}
|
||||
|
||||
func (s *AIModelStore) UpdateWithName(userID, id, name string, enabled bool, apiKey, customAPIURL, customModelName string) error {
|
||||
// Try exact ID match first
|
||||
var existingModel AIModel
|
||||
err := s.db.Where("user_id = ? AND id = ?", userID, id).First(&existingModel).Error
|
||||
@@ -182,6 +215,9 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
|
||||
"custom_model_name": customModelName,
|
||||
"updated_at": time.Now().UTC(),
|
||||
}
|
||||
if strings.TrimSpace(name) != "" {
|
||||
updates["name"] = strings.TrimSpace(name)
|
||||
}
|
||||
// If apiKey is not empty, update it (encryption handled by crypto.EncryptedString)
|
||||
if apiKey != "" {
|
||||
updates["api_key"] = crypto.EncryptedString(apiKey)
|
||||
@@ -200,6 +236,9 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
|
||||
"custom_model_name": customModelName,
|
||||
"updated_at": time.Now().UTC(),
|
||||
}
|
||||
if strings.TrimSpace(name) != "" {
|
||||
updates["name"] = strings.TrimSpace(name)
|
||||
}
|
||||
if apiKey != "" {
|
||||
updates["api_key"] = crypto.EncryptedString(apiKey)
|
||||
}
|
||||
@@ -218,31 +257,35 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get name from existing model with same provider
|
||||
// Try to get a sensible default name from an existing model with the same provider.
|
||||
var refModel AIModel
|
||||
var name string
|
||||
defaultName := ""
|
||||
if err := s.db.Where("provider = ?", provider).First(&refModel).Error; err == nil {
|
||||
name = refModel.Name
|
||||
defaultName = refModel.Name
|
||||
} else {
|
||||
if provider == "deepseek" {
|
||||
name = "DeepSeek AI"
|
||||
defaultName = "DeepSeek AI"
|
||||
} else if provider == "qwen" {
|
||||
name = "Qwen AI"
|
||||
defaultName = "Qwen AI"
|
||||
} else {
|
||||
name = provider + " AI"
|
||||
defaultName = provider + " AI"
|
||||
}
|
||||
}
|
||||
finalName := strings.TrimSpace(name)
|
||||
if finalName == "" {
|
||||
finalName = strings.TrimSpace(defaultName)
|
||||
}
|
||||
|
||||
newModelID := id
|
||||
if id == provider {
|
||||
newModelID = fmt.Sprintf("%s_%s", userID, provider)
|
||||
}
|
||||
|
||||
logger.Infof("✓ Creating new AI model configuration: ID=%s, Provider=%s, Name=%s", newModelID, provider, name)
|
||||
logger.Infof("✓ Creating new AI model configuration: ID=%s, Provider=%s, Name=%s", newModelID, provider, finalName)
|
||||
newModel := &AIModel{
|
||||
ID: newModelID,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
Name: finalName,
|
||||
Provider: provider,
|
||||
Enabled: enabled,
|
||||
APIKey: crypto.EncryptedString(apiKey),
|
||||
@@ -253,6 +296,43 @@ func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPI
|
||||
}
|
||||
|
||||
// Create creates an AI model
|
||||
// ResolveClaw402WalletKey returns the claw402 wallet private key for a user.
|
||||
// If preferredModelID is non-empty and points to a claw402 model, its key is returned first.
|
||||
// Otherwise the first enabled claw402 model in the user's model list is used.
|
||||
// Returns ("", nil) when no claw402 model is configured — callers should treat this as
|
||||
// "no paid data routing" rather than an error.
|
||||
func (s *AIModelStore) ResolveClaw402WalletKey(userID, preferredModelID string) (string, error) {
|
||||
if preferredModelID != "" {
|
||||
model, err := s.Get(userID, preferredModelID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load selected AI model")
|
||||
}
|
||||
if model.Provider == "claw402" {
|
||||
walletKey := string(model.APIKey)
|
||||
if walletKey == "" {
|
||||
return "", fmt.Errorf("selected claw402 model is missing wallet private key")
|
||||
}
|
||||
return walletKey, nil
|
||||
}
|
||||
}
|
||||
|
||||
models, err := s.List(userID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load AI models")
|
||||
}
|
||||
|
||||
for _, model := range models {
|
||||
if model == nil || model.Provider != "claw402" {
|
||||
continue
|
||||
}
|
||||
if walletKey := string(model.APIKey); walletKey != "" {
|
||||
return walletKey, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (s *AIModelStore) Create(userID, id, name, provider string, enabled bool, apiKey, customAPIURL string) error {
|
||||
model := &AIModel{
|
||||
ID: id,
|
||||
@@ -266,3 +346,16 @@ func (s *AIModelStore) Create(userID, id, name, provider string, enabled bool, a
|
||||
// Use FirstOrCreate to ignore if already exists
|
||||
return s.db.Where("id = ?", id).FirstOrCreate(model).Error
|
||||
}
|
||||
|
||||
// Delete removes a user-owned AI model configuration.
|
||||
func (s *AIModelStore) Delete(userID, id string) error {
|
||||
result := s.db.Where("user_id = ? AND id = ?", userID, id).Delete(&AIModel{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("ai model not found: id=%s, userID=%s", id, userID)
|
||||
}
|
||||
logger.Infof("🗑️ Deleted AI model: id=%s, userID=%s", id, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,15 +14,16 @@ type EquityStore struct {
|
||||
|
||||
// EquitySnapshot equity snapshot
|
||||
type EquitySnapshot struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TraderID string `gorm:"column:trader_id;not null;index:idx_equity_trader_time" json:"trader_id"`
|
||||
Timestamp time.Time `gorm:"not null;index:idx_equity_trader_time,sort:desc;index:idx_equity_timestamp,sort:desc" json:"timestamp"`
|
||||
TotalEquity float64 `gorm:"column:total_equity;not null;default:0" json:"total_equity"`
|
||||
Balance float64 `gorm:"not null;default:0" json:"balance"`
|
||||
UnrealizedPnL float64 `gorm:"column:unrealized_pnl;not null;default:0" json:"unrealized_pnl"`
|
||||
PositionCount int `gorm:"column:position_count;default:0" json:"position_count"`
|
||||
MarginUsedPct float64 `gorm:"column:margin_used_pct;default:0" json:"margin_used_pct"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TraderID string `gorm:"column:trader_id;not null;index:idx_equity_trader_time" json:"trader_id"`
|
||||
Timestamp time.Time `gorm:"not null;index:idx_equity_trader_time,sort:desc;index:idx_equity_timestamp,sort:desc" json:"timestamp"`
|
||||
TotalEquity float64 `gorm:"column:total_equity;not null;default:0" json:"total_equity"`
|
||||
Balance float64 `gorm:"not null;default:0" json:"balance"`
|
||||
AvailableBalance float64 `gorm:"column:available_balance;not null;default:0" json:"available_balance"`
|
||||
UnrealizedPnL float64 `gorm:"column:unrealized_pnl;not null;default:0" json:"unrealized_pnl"`
|
||||
PositionCount int `gorm:"column:position_count;default:0" json:"position_count"`
|
||||
MarginUsedPct float64 `gorm:"column:margin_used_pct;default:0" json:"margin_used_pct"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (EquitySnapshot) TableName() string { return "trader_equity_snapshots" }
|
||||
@@ -98,6 +99,7 @@ func (s *EquityStore) GetAllTradersLatest() (map[string]*EquitySnapshot, error)
|
||||
var snapshots []*EquitySnapshot
|
||||
err := s.db.Raw(`
|
||||
SELECT e.id, e.trader_id, e.timestamp, e.total_equity, e.balance,
|
||||
e.available_balance,
|
||||
e.unrealized_pnl, e.position_count, e.margin_used_pct, e.created_at
|
||||
FROM trader_equity_snapshots e
|
||||
INNER JOIN (
|
||||
@@ -159,12 +161,13 @@ func (s *EquityStore) MigrateFromDecision() (int64, error) {
|
||||
result := s.db.Exec(`
|
||||
INSERT INTO trader_equity_snapshots (
|
||||
trader_id, timestamp, total_equity, balance,
|
||||
unrealized_pnl, position_count, margin_used_pct
|
||||
available_balance, unrealized_pnl, position_count, margin_used_pct
|
||||
)
|
||||
SELECT
|
||||
dr.trader_id,
|
||||
dr.timestamp,
|
||||
das.total_balance,
|
||||
das.total_balance - das.total_unrealized_profit,
|
||||
das.available_balance,
|
||||
das.total_unrealized_profit,
|
||||
das.position_count,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"nofx/crypto"
|
||||
"nofx/logger"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -17,28 +18,29 @@ type ExchangeStore struct {
|
||||
|
||||
// Exchange exchange configuration
|
||||
type Exchange struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||
AccountName string `gorm:"column:account_name;not null;default:''" json:"account_name"`
|
||||
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Type string `gorm:"not null" json:"type"` // "cex" or "dex"
|
||||
Enabled bool `gorm:"default:false" json:"enabled"`
|
||||
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
|
||||
SecretKey crypto.EncryptedString `gorm:"column:secret_key;default:''" json:"secretKey"`
|
||||
Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"`
|
||||
Testnet bool `gorm:"default:false" json:"testnet"`
|
||||
HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"`
|
||||
HyperliquidUnifiedAcct bool `gorm:"column:hyperliquid_unified_account;default:true" json:"hyperliquidUnifiedAccount"` // Unified Account mode (Spot as collateral)
|
||||
AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"`
|
||||
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"`
|
||||
AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"`
|
||||
LighterWalletAddr string `gorm:"column:lighter_wallet_addr;default:''" json:"lighterWalletAddr"`
|
||||
LighterPrivateKey crypto.EncryptedString `gorm:"column:lighter_private_key;default:''" json:"lighterPrivateKey"`
|
||||
LighterAPIKeyPrivateKey crypto.EncryptedString `gorm:"column:lighter_api_key_private_key;default:''" json:"lighterAPIKeyPrivateKey"`
|
||||
LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
||||
AccountName string `gorm:"column:account_name;not null;default:''" json:"account_name"`
|
||||
UserID string `gorm:"column:user_id;not null;default:default;index" json:"user_id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Type string `gorm:"not null" json:"type"` // "cex" or "dex"
|
||||
Enabled bool `gorm:"default:false" json:"enabled"`
|
||||
APIKey crypto.EncryptedString `gorm:"column:api_key;default:''" json:"apiKey"`
|
||||
SecretKey crypto.EncryptedString `gorm:"column:secret_key;default:''" json:"secretKey"`
|
||||
Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"`
|
||||
Testnet bool `gorm:"default:false" json:"testnet"`
|
||||
HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"`
|
||||
HyperliquidUnifiedAcct bool `gorm:"column:hyperliquid_unified_account;default:true" json:"hyperliquidUnifiedAccount"` // Unified Account mode (Spot as collateral)
|
||||
HyperliquidBuilderApproved bool `gorm:"column:hyperliquid_builder_approved;default:false" json:"hyperliquidBuilderApproved"`
|
||||
AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"`
|
||||
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"`
|
||||
AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"`
|
||||
LighterWalletAddr string `gorm:"column:lighter_wallet_addr;default:''" json:"lighterWalletAddr"`
|
||||
LighterPrivateKey crypto.EncryptedString `gorm:"column:lighter_private_key;default:''" json:"lighterPrivateKey"`
|
||||
LighterAPIKeyPrivateKey crypto.EncryptedString `gorm:"column:lighter_api_key_private_key;default:''" json:"lighterAPIKeyPrivateKey"`
|
||||
LighterAPIKeyIndex int `gorm:"column:lighter_api_key_index;default:0" json:"lighterAPIKeyIndex"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Exchange) TableName() string { return "exchanges" }
|
||||
@@ -54,9 +56,15 @@ func (s *ExchangeStore) initTables() error {
|
||||
var tableExists int64
|
||||
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'exchanges'`).Scan(&tableExists)
|
||||
if tableExists > 0 {
|
||||
// Still run data migrations
|
||||
// Still run schema/data migrations
|
||||
if err := s.ensureHyperliquidBuilderApprovedColumn(); err != nil {
|
||||
logger.Warnf("Exchange builder approval column migration warning: %v", err)
|
||||
}
|
||||
s.migrateToMultiAccount()
|
||||
s.db.Model(&Exchange{}).Where("account_name = '' OR account_name IS NULL").Update("account_name", "Default")
|
||||
if err := s.cleanupIncompleteExchangeConfigs(); err != nil {
|
||||
logger.Warnf("Exchange cleanup migration warning: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -66,16 +74,64 @@ func (s *ExchangeStore) initTables() error {
|
||||
}
|
||||
|
||||
// Run migration to multi-account if needed
|
||||
if err := s.ensureHyperliquidBuilderApprovedColumn(); err != nil {
|
||||
logger.Warnf("Exchange builder approval column migration warning: %v", err)
|
||||
}
|
||||
if err := s.migrateToMultiAccount(); err != nil {
|
||||
logger.Warnf("Multi-account migration warning: %v", err)
|
||||
}
|
||||
|
||||
// Fix empty account_name for existing records
|
||||
s.db.Model(&Exchange{}).Where("account_name = '' OR account_name IS NULL").Update("account_name", "Default")
|
||||
if err := s.cleanupIncompleteExchangeConfigs(); err != nil {
|
||||
logger.Warnf("Exchange cleanup migration warning: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ExchangeStore) ensureHyperliquidBuilderApprovedColumn() error {
|
||||
if s.db.Migrator().HasColumn(&Exchange{}, "HyperliquidBuilderApproved") {
|
||||
return nil
|
||||
}
|
||||
return s.db.Migrator().AddColumn(&Exchange{}, "HyperliquidBuilderApproved")
|
||||
}
|
||||
|
||||
func (s *ExchangeStore) cleanupIncompleteExchangeConfigs() error {
|
||||
var exchanges []Exchange
|
||||
if err := s.db.Find(&exchanges).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, exchange := range exchanges {
|
||||
missing := MissingRequiredExchangeCredentialFields(
|
||||
exchange.ExchangeType,
|
||||
string(exchange.APIKey),
|
||||
string(exchange.SecretKey),
|
||||
string(exchange.Passphrase),
|
||||
exchange.HyperliquidWalletAddr,
|
||||
exchange.AsterUser,
|
||||
exchange.AsterSigner,
|
||||
string(exchange.AsterPrivateKey),
|
||||
exchange.LighterWalletAddr,
|
||||
string(exchange.LighterAPIKeyPrivateKey),
|
||||
)
|
||||
if len(missing) > 0 {
|
||||
if err := s.db.Delete(&Exchange{}, "id = ? AND user_id = ?", exchange.ID, exchange.UserID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Infof("🧹 Removed incomplete exchange config during migration: id=%s user=%s missing=%s", exchange.ID, exchange.UserID, strings.Join(missing, ","))
|
||||
continue
|
||||
}
|
||||
if !exchange.Enabled {
|
||||
if err := s.db.Model(&Exchange{}).Where("id = ? AND user_id = ?", exchange.ID, exchange.UserID).Update("enabled", true).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Infof("🧹 Enabled complete exchange config during migration: id=%s user=%s", exchange.ID, exchange.UserID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateToMultiAccount migrates old schema (id=exchange_type) to new schema (id=UUID)
|
||||
func (s *ExchangeStore) migrateToMultiAccount() error {
|
||||
// Check if migration is needed by looking for old-style IDs (non-UUID)
|
||||
@@ -184,10 +240,14 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
|
||||
// Create creates a new exchange account with UUID
|
||||
func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool,
|
||||
apiKey, secretKey, passphrase string, testnet bool,
|
||||
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
|
||||
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool, hyperliquidBuilderApproved bool,
|
||||
asterUser, asterSigner, asterPrivateKey,
|
||||
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) {
|
||||
|
||||
if missing := MissingRequiredExchangeCredentialFields(exchangeType, apiKey, secretKey, passphrase, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterApiKeyPrivateKey); len(missing) > 0 {
|
||||
return "", fmt.Errorf("missing required exchange fields: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
name, typ := getExchangeNameAndType(exchangeType)
|
||||
|
||||
@@ -199,26 +259,27 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
|
||||
userID, exchangeType, accountName, id)
|
||||
|
||||
exchange := &Exchange{
|
||||
ID: id,
|
||||
ExchangeType: exchangeType,
|
||||
AccountName: accountName,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
Type: typ,
|
||||
Enabled: enabled,
|
||||
APIKey: crypto.EncryptedString(apiKey),
|
||||
SecretKey: crypto.EncryptedString(secretKey),
|
||||
Passphrase: crypto.EncryptedString(passphrase),
|
||||
Testnet: testnet,
|
||||
HyperliquidWalletAddr: hyperliquidWalletAddr,
|
||||
HyperliquidUnifiedAcct: hyperliquidUnifiedAcct,
|
||||
AsterUser: asterUser,
|
||||
AsterSigner: asterSigner,
|
||||
AsterPrivateKey: crypto.EncryptedString(asterPrivateKey),
|
||||
LighterWalletAddr: lighterWalletAddr,
|
||||
LighterPrivateKey: crypto.EncryptedString(lighterPrivateKey),
|
||||
LighterAPIKeyPrivateKey: crypto.EncryptedString(lighterApiKeyPrivateKey),
|
||||
LighterAPIKeyIndex: lighterApiKeyIndex,
|
||||
ID: id,
|
||||
ExchangeType: exchangeType,
|
||||
AccountName: accountName,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
Type: typ,
|
||||
Enabled: true,
|
||||
APIKey: crypto.EncryptedString(apiKey),
|
||||
SecretKey: crypto.EncryptedString(secretKey),
|
||||
Passphrase: crypto.EncryptedString(passphrase),
|
||||
Testnet: testnet,
|
||||
HyperliquidWalletAddr: hyperliquidWalletAddr,
|
||||
HyperliquidUnifiedAcct: hyperliquidUnifiedAcct,
|
||||
HyperliquidBuilderApproved: exchangeType == "hyperliquid" && hyperliquidBuilderApproved,
|
||||
AsterUser: asterUser,
|
||||
AsterSigner: asterSigner,
|
||||
AsterPrivateKey: crypto.EncryptedString(asterPrivateKey),
|
||||
LighterWalletAddr: lighterWalletAddr,
|
||||
LighterPrivateKey: crypto.EncryptedString(lighterPrivateKey),
|
||||
LighterAPIKeyPrivateKey: crypto.EncryptedString(lighterApiKeyPrivateKey),
|
||||
LighterAPIKeyIndex: lighterApiKeyIndex,
|
||||
}
|
||||
|
||||
if err := s.db.Create(exchange).Error; err != nil {
|
||||
@@ -229,21 +290,22 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
|
||||
|
||||
// Update updates exchange configuration by UUID
|
||||
func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool,
|
||||
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
|
||||
hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool, hyperliquidBuilderApproved bool,
|
||||
asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
|
||||
|
||||
logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled)
|
||||
logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s", userID, id)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"enabled": enabled,
|
||||
"testnet": testnet,
|
||||
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
|
||||
"hyperliquid_unified_account": hyperliquidUnifiedAcct,
|
||||
"aster_user": asterUser,
|
||||
"aster_signer": asterSigner,
|
||||
"lighter_wallet_addr": lighterWalletAddr,
|
||||
"lighter_api_key_index": lighterApiKeyIndex,
|
||||
"updated_at": time.Now().UTC(),
|
||||
"enabled": true,
|
||||
"testnet": testnet,
|
||||
"hyperliquid_wallet_addr": hyperliquidWalletAddr,
|
||||
"hyperliquid_unified_account": hyperliquidUnifiedAcct,
|
||||
"hyperliquid_builder_approved": hyperliquidBuilderApproved,
|
||||
"aster_user": asterUser,
|
||||
"aster_signer": asterSigner,
|
||||
"lighter_wallet_addr": lighterWalletAddr,
|
||||
"lighter_api_key_index": lighterApiKeyIndex,
|
||||
"updated_at": time.Now().UTC(),
|
||||
}
|
||||
|
||||
// Only update encrypted fields if not empty
|
||||
@@ -314,7 +376,7 @@ func (s *ExchangeStore) CreateLegacy(userID, id, name, typ string, enabled bool,
|
||||
// Check if this is an old-style ID (exchange type as ID)
|
||||
if id == "binance" || id == "bybit" || id == "okx" || id == "bitget" || id == "hyperliquid" || id == "aster" || id == "lighter" {
|
||||
_, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet,
|
||||
hyperliquidWalletAddr, true, // Default to Unified Account mode
|
||||
hyperliquidWalletAddr, true, false, // Default to Unified Account mode; builder approval must be explicit
|
||||
asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -258,12 +258,17 @@ func (s *OrderStore) GetTraderOrdersFiltered(traderID string, symbol string, sta
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// GetOrderFills gets order's fill records
|
||||
func (s *OrderStore) GetOrderFills(orderID int64) ([]*TraderFill, error) {
|
||||
// GetOrderFills gets fill records for a specific order. The traderID arg
|
||||
// scopes the join so a caller cannot read fills for an order that does not
|
||||
// belong to their trader (IDOR boundary). Pass an empty traderID only from
|
||||
// trusted internal callers that have already verified ownership.
|
||||
func (s *OrderStore) GetOrderFills(traderID string, orderID int64) ([]*TraderFill, error) {
|
||||
q := s.db.Where("order_id = ?", orderID)
|
||||
if traderID != "" {
|
||||
q = q.Where("trader_id = ?", traderID)
|
||||
}
|
||||
var fills []*TraderFill
|
||||
err := s.db.Where("order_id = ?", orderID).
|
||||
Order("created_at ASC").
|
||||
Find(&fills).Error
|
||||
err := q.Order("created_at ASC").Find(&fills).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query fills: %w", err)
|
||||
}
|
||||
|
||||
@@ -116,8 +116,8 @@ type TraderPosition struct {
|
||||
Status string `gorm:"column:status;default:OPEN;index:idx_positions_status" json:"status"`
|
||||
CloseReason string `gorm:"column:close_reason;default:''" json:"close_reason"`
|
||||
Source string `gorm:"column:source;default:system" json:"source"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC
|
||||
}
|
||||
|
||||
// TableName returns the table name
|
||||
@@ -198,14 +198,14 @@ func (s *PositionStore) Create(pos *TraderPosition) error {
|
||||
func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID string, realizedPnL float64, fee float64, closeReason string) error {
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"exit_price": exitPrice,
|
||||
"exit_price": exitPrice,
|
||||
"exit_order_id": exitOrderID,
|
||||
"exit_time": nowMs,
|
||||
"realized_pnl": realizedPnL,
|
||||
"fee": fee,
|
||||
"status": "CLOSED",
|
||||
"close_reason": closeReason,
|
||||
"updated_at": nowMs,
|
||||
"exit_time": nowMs,
|
||||
"realized_pnl": realizedPnL,
|
||||
"fee": fee,
|
||||
"status": "CLOSED",
|
||||
"close_reason": closeReason,
|
||||
"updated_at": nowMs,
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -311,15 +311,15 @@ func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrde
|
||||
}
|
||||
|
||||
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"quantity": quantity,
|
||||
"exit_price": exitPrice,
|
||||
"exit_order_id": exitOrderID,
|
||||
"exit_time": exitTimeMs,
|
||||
"realized_pnl": totalRealizedPnL,
|
||||
"fee": totalFee,
|
||||
"status": "CLOSED",
|
||||
"close_reason": closeReason,
|
||||
"updated_at": time.Now().UTC().UnixMilli(),
|
||||
"quantity": quantity,
|
||||
"exit_price": exitPrice,
|
||||
"exit_order_id": exitOrderID,
|
||||
"exit_time": exitTimeMs,
|
||||
"realized_pnl": totalRealizedPnL,
|
||||
"fee": totalFee,
|
||||
"status": "CLOSED",
|
||||
"close_reason": closeReason,
|
||||
"updated_at": time.Now().UTC().UnixMilli(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ func (s *PositionStore) GetOpenPositions(traderID string) ([]*TraderPosition, er
|
||||
// GetOpenPositionBySymbol gets open position for specified symbol and direction
|
||||
func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (*TraderPosition, error) {
|
||||
var pos TraderPosition
|
||||
err := s.db.Where("trader_id = ? AND symbol = ? AND side = ? AND status = ?", traderID, symbol, side, "OPEN").
|
||||
err := s.db.Where("trader_id = ? AND symbol = ? AND UPPER(side) = UPPER(?) AND status = ?", traderID, symbol, side, "OPEN").
|
||||
Order("entry_time DESC").
|
||||
First(&pos).Error
|
||||
|
||||
@@ -365,7 +365,7 @@ func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (
|
||||
// Try without USDT suffix for backward compatibility
|
||||
if strings.HasSuffix(symbol, "USDT") {
|
||||
baseSymbol := strings.TrimSuffix(symbol, "USDT")
|
||||
err = s.db.Where("trader_id = ? AND symbol = ? AND side = ? AND status = ?", traderID, baseSymbol, side, "OPEN").
|
||||
err = s.db.Where("trader_id = ? AND symbol = ? AND UPPER(side) = UPPER(?) AND status = ?", traderID, baseSymbol, side, "OPEN").
|
||||
Order("entry_time DESC").
|
||||
First(&pos).Error
|
||||
if err == nil {
|
||||
@@ -382,11 +382,54 @@ func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (
|
||||
|
||||
// GetClosedPositions gets closed positions
|
||||
func (s *PositionStore) GetClosedPositions(traderID string, limit int) ([]*TraderPosition, error) {
|
||||
return s.GetClosedPositionsByTraderFilters([]string{traderID}, nil, limit)
|
||||
}
|
||||
|
||||
func (s *PositionStore) closedPositionsByTraderFilters(traderIDs []string, traderIDPatterns []string) *gorm.DB {
|
||||
query := s.db.Where("status = ?", "CLOSED")
|
||||
|
||||
conditions := make([]string, 0, len(traderIDs)+len(traderIDPatterns))
|
||||
args := make([]interface{}, 0, len(traderIDs)+len(traderIDPatterns))
|
||||
|
||||
cleanTraderIDs := make([]string, 0, len(traderIDs))
|
||||
for _, traderID := range traderIDs {
|
||||
traderID = strings.TrimSpace(traderID)
|
||||
if traderID != "" {
|
||||
cleanTraderIDs = append(cleanTraderIDs, traderID)
|
||||
}
|
||||
}
|
||||
if len(cleanTraderIDs) > 0 {
|
||||
conditions = append(conditions, "trader_id IN ?")
|
||||
args = append(args, cleanTraderIDs)
|
||||
}
|
||||
|
||||
for _, pattern := range traderIDPatterns {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
conditions = append(conditions, "trader_id LIKE ?")
|
||||
args = append(args, pattern)
|
||||
}
|
||||
|
||||
if len(conditions) == 0 {
|
||||
return query.Where("1 = 0")
|
||||
}
|
||||
|
||||
return query.Where("("+strings.Join(conditions, " OR ")+")", args...)
|
||||
}
|
||||
|
||||
// GetClosedPositionsByTraderFilters gets closed positions for explicit trader IDs
|
||||
// and legacy trader ID patterns. Patterns are used only for same-user Autopilot
|
||||
// history continuity when an old trader row was deleted but its position records remain.
|
||||
func (s *PositionStore) GetClosedPositionsByTraderFilters(traderIDs []string, traderIDPatterns []string, limit int) ([]*TraderPosition, error) {
|
||||
var positions []*TraderPosition
|
||||
err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").
|
||||
Order("exit_time DESC").
|
||||
Limit(limit).
|
||||
Find(&positions).Error
|
||||
query := s.closedPositionsByTraderFilters(traderIDs, traderIDPatterns).Order("exit_time DESC")
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
|
||||
err := query.Find(&positions).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query closed positions: %w", err)
|
||||
}
|
||||
|
||||
@@ -56,18 +56,16 @@ func (s *PositionStore) GetPositionStats(traderID string) (map[string]interface{
|
||||
|
||||
// GetFullStats gets complete trading statistics
|
||||
func (s *PositionStore) GetFullStats(traderID string) (*TraderStats, error) {
|
||||
return s.GetFullStatsByTraderFilters([]string{traderID}, nil)
|
||||
}
|
||||
|
||||
// GetFullStatsByTraderFilters gets complete trading statistics for explicit
|
||||
// trader IDs plus optional legacy trader ID patterns.
|
||||
func (s *PositionStore) GetFullStatsByTraderFilters(traderIDs []string, traderIDPatterns []string) (*TraderStats, error) {
|
||||
stats := &TraderStats{}
|
||||
|
||||
var count int64
|
||||
if err := s.db.Model(&TraderPosition{}).Where("trader_id = ? AND status = ?", traderID, "CLOSED").Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
var positions []TraderPosition
|
||||
err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").
|
||||
err := s.closedPositionsByTraderFilters(traderIDs, traderIDPatterns).
|
||||
Order("exit_time ASC").
|
||||
Find(&positions).Error
|
||||
if err != nil {
|
||||
@@ -234,8 +232,14 @@ type SymbolStats struct {
|
||||
|
||||
// GetSymbolStats gets per-symbol trading statistics
|
||||
func (s *PositionStore) GetSymbolStats(traderID string, limit int) ([]SymbolStats, error) {
|
||||
return s.GetSymbolStatsByTraderFilters([]string{traderID}, nil, limit)
|
||||
}
|
||||
|
||||
// GetSymbolStatsByTraderFilters gets per-symbol trading statistics for explicit
|
||||
// trader IDs plus optional legacy trader ID patterns.
|
||||
func (s *PositionStore) GetSymbolStatsByTraderFilters(traderIDs []string, traderIDPatterns []string, limit int) ([]SymbolStats, error) {
|
||||
var positions []TraderPosition
|
||||
err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").Find(&positions).Error
|
||||
err := s.closedPositionsByTraderFilters(traderIDs, traderIDPatterns).Find(&positions).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query symbol stats: %w", err)
|
||||
}
|
||||
@@ -311,8 +315,8 @@ func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats
|
||||
}
|
||||
|
||||
rangeStats := map[string]*struct {
|
||||
count int
|
||||
wins int
|
||||
count int
|
||||
wins int
|
||||
totalPnL float64
|
||||
}{
|
||||
"<1h": {},
|
||||
@@ -374,8 +378,14 @@ type DirectionStats struct {
|
||||
|
||||
// GetDirectionStats analyzes long vs short performance
|
||||
func (s *PositionStore) GetDirectionStats(traderID string) ([]DirectionStats, error) {
|
||||
return s.GetDirectionStatsByTraderFilters([]string{traderID}, nil)
|
||||
}
|
||||
|
||||
// GetDirectionStatsByTraderFilters analyzes long vs short performance for
|
||||
// explicit trader IDs plus optional legacy trader ID patterns.
|
||||
func (s *PositionStore) GetDirectionStatsByTraderFilters(traderIDs []string, traderIDPatterns []string) ([]DirectionStats, error) {
|
||||
var positions []TraderPosition
|
||||
err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").Find(&positions).Error
|
||||
err := s.closedPositionsByTraderFilters(traderIDs, traderIDPatterns).Find(&positions).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query direction stats: %w", err)
|
||||
}
|
||||
|
||||
136
store/position_test.go
Normal file
136
store/position_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestGetOpenPositionBySymbolMatchesSideCaseInsensitively(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open in-memory sqlite: %v", err)
|
||||
}
|
||||
|
||||
positions := NewPositionStore(db)
|
||||
if err := positions.InitTables(); err != nil {
|
||||
t.Fatalf("init position table: %v", err)
|
||||
}
|
||||
|
||||
entryTime := time.Now().Add(-5 * time.Minute).UnixMilli()
|
||||
if err := positions.Create(&TraderPosition{
|
||||
TraderID: "trader-1",
|
||||
Symbol: "AAVEUSDT",
|
||||
Side: "LONG",
|
||||
Quantity: 0.27,
|
||||
EntryPrice: 88.519,
|
||||
EntryTime: entryTime,
|
||||
}); err != nil {
|
||||
t.Fatalf("create position: %v", err)
|
||||
}
|
||||
|
||||
got, err := positions.GetOpenPositionBySymbol("trader-1", "AAVEUSDT", "long")
|
||||
if err != nil {
|
||||
t.Fatalf("get open position: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected open position")
|
||||
}
|
||||
if got.EntryTime != entryTime {
|
||||
t.Fatalf("entry time mismatch: got %d want %d", got.EntryTime, entryTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetClosedPositionsByTraderFiltersIncludesLegacyAutopilotIDs(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open in-memory sqlite: %v", err)
|
||||
}
|
||||
|
||||
positions := NewPositionStore(db)
|
||||
if err := positions.InitTables(); err != nil {
|
||||
t.Fatalf("init position table: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
rows := []*TraderPosition{
|
||||
{
|
||||
TraderID: "current-trader",
|
||||
Symbol: "xyz:SP500",
|
||||
Side: "LONG",
|
||||
Quantity: 1,
|
||||
EntryPrice: 100,
|
||||
EntryTime: now - 3000,
|
||||
ExitPrice: 101,
|
||||
ExitTime: now - 2000,
|
||||
RealizedPnL: 1,
|
||||
Status: "CLOSED",
|
||||
CreatedAt: now - 3000,
|
||||
UpdatedAt: now - 2000,
|
||||
CloseReason: "sync",
|
||||
ExchangeType: "hyperliquid",
|
||||
},
|
||||
{
|
||||
TraderID: "exchange_user-123_claw402_111",
|
||||
Symbol: "AAVEUSDT",
|
||||
Side: "LONG",
|
||||
Quantity: 2,
|
||||
EntryPrice: 50,
|
||||
EntryTime: now - 5000,
|
||||
ExitPrice: 49,
|
||||
ExitTime: now - 4000,
|
||||
RealizedPnL: -2,
|
||||
Status: "CLOSED",
|
||||
CreatedAt: now - 5000,
|
||||
UpdatedAt: now - 4000,
|
||||
CloseReason: "sync",
|
||||
ExchangeType: "hyperliquid",
|
||||
},
|
||||
{
|
||||
TraderID: "exchange_other-user_claw402_222",
|
||||
Symbol: "LITUSDT",
|
||||
Side: "LONG",
|
||||
Quantity: 3,
|
||||
EntryPrice: 10,
|
||||
EntryTime: now - 7000,
|
||||
ExitPrice: 12,
|
||||
ExitTime: now - 6000,
|
||||
RealizedPnL: 6,
|
||||
Status: "CLOSED",
|
||||
CreatedAt: now - 7000,
|
||||
UpdatedAt: now - 6000,
|
||||
CloseReason: "sync",
|
||||
ExchangeType: "hyperliquid",
|
||||
},
|
||||
}
|
||||
for _, row := range rows {
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
t.Fatalf("create position: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := positions.GetClosedPositionsByTraderFilters(
|
||||
[]string{"current-trader"},
|
||||
[]string{"%_user-123_claw402_%"},
|
||||
100,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get closed positions: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected current + same-user legacy positions, got %d", len(got))
|
||||
}
|
||||
|
||||
stats, err := positions.GetFullStatsByTraderFilters(
|
||||
[]string{"current-trader"},
|
||||
[]string{"%_user-123_claw402_%"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get stats: %v", err)
|
||||
}
|
||||
if stats.TotalTrades != 2 || stats.TotalPnL != -1 {
|
||||
t.Fatalf("unexpected stats: trades=%d pnl=%.2f", stats.TotalTrades, stats.TotalPnL)
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,25 @@ const (
|
||||
MaxTimeframes = 4
|
||||
MinKlineCount = 10
|
||||
MaxKlineCount = 30
|
||||
MinLeverage = 1
|
||||
MaxBTCETHLeverage = 20
|
||||
MaxAltLeverage = 20
|
||||
MinPositionRatio = 0.5
|
||||
MaxPositionRatio = 10.0
|
||||
MinRiskReward = 1.0
|
||||
MaxRiskReward = 10.0
|
||||
MinMarginUsage = 0.1
|
||||
MaxMarginUsage = 1.0
|
||||
MinPositionSize = 10.0
|
||||
MaxPositionSize = 1000.0
|
||||
MinConfidence = 50
|
||||
MaxConfidence = 100
|
||||
)
|
||||
|
||||
// ClampLimits enforces product-level limits on strategy config to prevent token overflow.
|
||||
func (c *StrategyConfig) ClampLimits() {
|
||||
c.NormalizeProductSchema()
|
||||
|
||||
// Clamp coin source limits
|
||||
if c.CoinSource.AI500Limit > MaxCandidateCoins {
|
||||
c.CoinSource.AI500Limit = MaxCandidateCoins
|
||||
@@ -31,6 +46,9 @@ func (c *StrategyConfig) ClampLimits() {
|
||||
if c.CoinSource.OILowLimit > MaxCandidateCoins {
|
||||
c.CoinSource.OILowLimit = MaxCandidateCoins
|
||||
}
|
||||
if c.CoinSource.VergexLimit > MaxCandidateCoins {
|
||||
c.CoinSource.VergexLimit = MaxCandidateCoins
|
||||
}
|
||||
|
||||
// Clamp static coins
|
||||
if len(c.CoinSource.StaticCoins) > MaxCandidateCoins {
|
||||
@@ -54,10 +72,519 @@ func (c *StrategyConfig) ClampLimits() {
|
||||
}
|
||||
|
||||
// Clamp max positions
|
||||
if c.RiskControl.MaxPositions < 1 {
|
||||
c.RiskControl.MaxPositions = 1
|
||||
}
|
||||
if c.RiskControl.MaxPositions > MaxPositions {
|
||||
c.RiskControl.MaxPositions = MaxPositions
|
||||
}
|
||||
|
||||
// Clamp leverage limits to the same bounds as the manual config UI.
|
||||
if c.RiskControl.BTCETHMaxLeverage < MinLeverage {
|
||||
c.RiskControl.BTCETHMaxLeverage = MinLeverage
|
||||
}
|
||||
if c.RiskControl.BTCETHMaxLeverage > MaxBTCETHLeverage {
|
||||
c.RiskControl.BTCETHMaxLeverage = MaxBTCETHLeverage
|
||||
}
|
||||
if c.RiskControl.AltcoinMaxLeverage < MinLeverage {
|
||||
c.RiskControl.AltcoinMaxLeverage = MinLeverage
|
||||
}
|
||||
if c.RiskControl.AltcoinMaxLeverage > MaxAltLeverage {
|
||||
c.RiskControl.AltcoinMaxLeverage = MaxAltLeverage
|
||||
}
|
||||
|
||||
// Clamp position value ratio limits.
|
||||
if c.RiskControl.BTCETHMaxPositionValueRatio < MinPositionRatio {
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = MinPositionRatio
|
||||
}
|
||||
if c.RiskControl.BTCETHMaxPositionValueRatio > MaxPositionRatio {
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = MaxPositionRatio
|
||||
}
|
||||
if c.RiskControl.AltcoinMaxPositionValueRatio < MinPositionRatio {
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = MinPositionRatio
|
||||
}
|
||||
if c.RiskControl.AltcoinMaxPositionValueRatio > MaxPositionRatio {
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = MaxPositionRatio
|
||||
}
|
||||
|
||||
// Clamp risk parameters and entry requirements.
|
||||
if c.RiskControl.MinRiskRewardRatio < MinRiskReward {
|
||||
c.RiskControl.MinRiskRewardRatio = MinRiskReward
|
||||
}
|
||||
if c.RiskControl.MinRiskRewardRatio > MaxRiskReward {
|
||||
c.RiskControl.MinRiskRewardRatio = MaxRiskReward
|
||||
}
|
||||
if c.RiskControl.MaxMarginUsage < MinMarginUsage {
|
||||
c.RiskControl.MaxMarginUsage = MinMarginUsage
|
||||
}
|
||||
if c.RiskControl.MaxMarginUsage > MaxMarginUsage {
|
||||
c.RiskControl.MaxMarginUsage = MaxMarginUsage
|
||||
}
|
||||
if c.RiskControl.MinPositionSize < MinPositionSize {
|
||||
c.RiskControl.MinPositionSize = MinPositionSize
|
||||
}
|
||||
if c.RiskControl.MinPositionSize > MaxPositionSize {
|
||||
c.RiskControl.MinPositionSize = MaxPositionSize
|
||||
}
|
||||
if c.RiskControl.MinConfidence < MinConfidence {
|
||||
c.RiskControl.MinConfidence = MinConfidence
|
||||
}
|
||||
if c.RiskControl.MinConfidence > MaxConfidence {
|
||||
c.RiskControl.MinConfidence = MaxConfidence
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeProductSchema keeps saved strategy JSON aligned with the product
|
||||
// editor schema. LLMs may emit user-facing labels such as "AI500"; persistence
|
||||
// must use the exact frontend/backend enum values.
|
||||
func (c *StrategyConfig) NormalizeProductSchema() {
|
||||
c.StrategyType = normalizeStrategyType(c.StrategyType)
|
||||
c.CoinSource.StaticCoins = normalizeSymbols(c.CoinSource.StaticCoins)
|
||||
c.CoinSource.ExcludedCoins = normalizeSymbols(c.CoinSource.ExcludedCoins)
|
||||
c.CoinSource.SourceType = normalizeCoinSourceType(c.CoinSource.SourceType)
|
||||
if c.CoinSource.SourceType == "" {
|
||||
c.CoinSource.SourceType = inferCoinSourceType(c.CoinSource)
|
||||
}
|
||||
|
||||
switch c.CoinSource.SourceType {
|
||||
case "ai500":
|
||||
c.CoinSource.UseAI500 = true
|
||||
c.CoinSource.UseOITop = false
|
||||
c.CoinSource.UseOILow = false
|
||||
c.CoinSource.UseHyperAll = false
|
||||
c.CoinSource.UseHyperMain = false
|
||||
if c.CoinSource.AI500Limit <= 0 {
|
||||
c.CoinSource.AI500Limit = 3
|
||||
}
|
||||
case "oi_top":
|
||||
c.CoinSource.UseAI500 = false
|
||||
c.CoinSource.UseOITop = true
|
||||
c.CoinSource.UseOILow = false
|
||||
c.CoinSource.UseHyperAll = false
|
||||
c.CoinSource.UseHyperMain = false
|
||||
if c.CoinSource.OITopLimit <= 0 {
|
||||
c.CoinSource.OITopLimit = 3
|
||||
}
|
||||
case "oi_low":
|
||||
c.CoinSource.UseAI500 = false
|
||||
c.CoinSource.UseOITop = false
|
||||
c.CoinSource.UseOILow = true
|
||||
c.CoinSource.UseHyperAll = false
|
||||
c.CoinSource.UseHyperMain = false
|
||||
if c.CoinSource.OILowLimit <= 0 {
|
||||
c.CoinSource.OILowLimit = 3
|
||||
}
|
||||
case "static":
|
||||
c.CoinSource.UseAI500 = false
|
||||
c.CoinSource.UseOITop = false
|
||||
c.CoinSource.UseOILow = false
|
||||
c.CoinSource.UseHyperAll = false
|
||||
c.CoinSource.UseHyperMain = false
|
||||
case "hyper_all":
|
||||
c.CoinSource.UseAI500 = false
|
||||
c.CoinSource.UseOITop = false
|
||||
c.CoinSource.UseOILow = false
|
||||
c.CoinSource.UseHyperAll = true
|
||||
c.CoinSource.UseHyperMain = false
|
||||
case "hyper_main":
|
||||
c.CoinSource.UseAI500 = false
|
||||
c.CoinSource.UseOITop = false
|
||||
c.CoinSource.UseOILow = false
|
||||
c.CoinSource.UseHyperAll = false
|
||||
c.CoinSource.UseHyperMain = true
|
||||
if c.CoinSource.HyperMainLimit <= 0 {
|
||||
c.CoinSource.HyperMainLimit = 30
|
||||
}
|
||||
case "hyper_rank":
|
||||
c.CoinSource.UseAI500 = false
|
||||
c.CoinSource.UseOITop = false
|
||||
c.CoinSource.UseOILow = false
|
||||
c.CoinSource.UseHyperAll = false
|
||||
c.CoinSource.UseHyperMain = false
|
||||
if c.CoinSource.HyperRankCategory == "" {
|
||||
c.CoinSource.HyperRankCategory = "stock"
|
||||
}
|
||||
if c.CoinSource.HyperRankDirection == "" {
|
||||
c.CoinSource.HyperRankDirection = "gainers"
|
||||
}
|
||||
if c.CoinSource.HyperRankLimit <= 0 {
|
||||
c.CoinSource.HyperRankLimit = 5
|
||||
}
|
||||
case "vergex_signal":
|
||||
c.CoinSource.UseAI500 = false
|
||||
c.CoinSource.UseOITop = false
|
||||
c.CoinSource.UseOILow = false
|
||||
c.CoinSource.UseHyperAll = false
|
||||
c.CoinSource.UseHyperMain = false
|
||||
minLimit := 10
|
||||
if len(c.CoinSource.StaticCoins) > 0 {
|
||||
minLimit = len(c.CoinSource.StaticCoins)
|
||||
if minLimit > MaxCandidateCoins {
|
||||
minLimit = MaxCandidateCoins
|
||||
}
|
||||
}
|
||||
if c.CoinSource.VergexLimit < minLimit {
|
||||
c.CoinSource.VergexLimit = minLimit
|
||||
}
|
||||
if c.CoinSource.VergexMarketType == "" {
|
||||
c.CoinSource.VergexMarketType = "all"
|
||||
}
|
||||
if c.CoinSource.VergexChain == "" {
|
||||
c.CoinSource.VergexChain = "hyperliquid"
|
||||
}
|
||||
default:
|
||||
c.CoinSource.SourceType = "vergex_signal"
|
||||
c.CoinSource.UseAI500 = false
|
||||
c.CoinSource.UseOITop = false
|
||||
c.CoinSource.UseOILow = false
|
||||
c.CoinSource.UseHyperAll = false
|
||||
c.CoinSource.UseHyperMain = false
|
||||
minLimit := 10
|
||||
if len(c.CoinSource.StaticCoins) > 0 {
|
||||
minLimit = len(c.CoinSource.StaticCoins)
|
||||
if minLimit > MaxCandidateCoins {
|
||||
minLimit = MaxCandidateCoins
|
||||
}
|
||||
}
|
||||
if c.CoinSource.VergexLimit < minLimit {
|
||||
c.CoinSource.VergexLimit = minLimit
|
||||
}
|
||||
if c.CoinSource.VergexMarketType == "" {
|
||||
c.CoinSource.VergexMarketType = "all"
|
||||
}
|
||||
if c.CoinSource.VergexChain == "" {
|
||||
c.CoinSource.VergexChain = "hyperliquid"
|
||||
}
|
||||
}
|
||||
|
||||
c.Indicators.Klines.PrimaryTimeframe = normalizeTimeframe(c.Indicators.Klines.PrimaryTimeframe)
|
||||
c.Indicators.Klines.LongerTimeframe = normalizeTimeframe(c.Indicators.Klines.LongerTimeframe)
|
||||
c.Indicators.Klines.SelectedTimeframes = normalizeTimeframes(c.Indicators.Klines.SelectedTimeframes)
|
||||
if len(c.Indicators.Klines.SelectedTimeframes) > 0 {
|
||||
c.Indicators.Klines.EnableMultiTimeframe = true
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStrategyType(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
switch value {
|
||||
case "grid", "grid_strategy", "grid-trading", "grid trading", "grid_trading", "网格", "网格策略", "网格交易":
|
||||
return "grid_trading"
|
||||
case "", "ai", "ai_strategy", "ai-trading", "ai trading", "ai_trading", "ai策略", "ai 策略", "ai交易策略", "ai智能策略":
|
||||
return "ai_trading"
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCoinSourceType(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
compact := strings.NewReplacer(" ", "", "_", "", "-", "", "数据源", "", "选币", "", "币种", "").Replace(value)
|
||||
switch {
|
||||
case compact == "":
|
||||
return ""
|
||||
case strings.Contains(compact, "ai500"):
|
||||
return "ai500"
|
||||
case strings.Contains(compact, "oitop") || strings.Contains(value, "oi top") || strings.Contains(value, "持仓量最高") || strings.Contains(value, "持仓量靠前"):
|
||||
return "oi_top"
|
||||
case strings.Contains(compact, "oilow") || strings.Contains(value, "oi low") || strings.Contains(value, "持仓量最低") || strings.Contains(value, "持仓量较低"):
|
||||
return "oi_low"
|
||||
case strings.Contains(compact, "hyperrank"):
|
||||
return "hyper_rank"
|
||||
case strings.Contains(compact, "vergex") || strings.Contains(compact, "claw402") || strings.Contains(compact, "dynamicranking") || strings.Contains(value, "动态榜单") || strings.Contains(value, "涨幅榜") || strings.Contains(value, "信号榜"):
|
||||
return "vergex_signal"
|
||||
case strings.Contains(compact, "hyperall"):
|
||||
return "hyper_all"
|
||||
case strings.Contains(compact, "hypermain"):
|
||||
return "hyper_main"
|
||||
case strings.Contains(value, "static") || strings.Contains(value, "固定") || strings.Contains(value, "静态"):
|
||||
return "static"
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func inferCoinSourceType(source CoinSourceConfig) string {
|
||||
switch {
|
||||
case len(source.StaticCoins) > 0:
|
||||
return "static"
|
||||
case source.UseAI500:
|
||||
return "ai500"
|
||||
case source.UseOITop:
|
||||
return "oi_top"
|
||||
case source.UseOILow:
|
||||
return "oi_low"
|
||||
case source.UseHyperAll:
|
||||
return "hyper_all"
|
||||
case source.UseHyperMain:
|
||||
return "hyper_main"
|
||||
case source.VergexLimit > 0 || source.VergexMarketType != "" || source.VergexChain != "" || source.VergexLiqBand != "":
|
||||
return "vergex_signal"
|
||||
case source.HyperRankCategory != "" || source.HyperRankDirection != "" || source.HyperRankLimit > 0:
|
||||
return "hyper_rank"
|
||||
default:
|
||||
return "vergex_signal"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeSymbols(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, value := range splitLooseStringList(values) {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
value = strings.Trim(value, ",,;; ")
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeTimeframes(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, value := range splitLooseStringList(values) {
|
||||
tf := normalizeTimeframe(value)
|
||||
if tf == "" || seen[tf] {
|
||||
continue
|
||||
}
|
||||
seen[tf] = true
|
||||
out = append(out, tf)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitLooseStringList(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
joined := strings.TrimSpace(strings.Join(values, ","))
|
||||
if strings.HasPrefix(joined, "[") && strings.HasSuffix(joined, "]") {
|
||||
var parsed []string
|
||||
if err := json.Unmarshal([]byte(joined), &parsed); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
parts := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
|
||||
var parsed []string
|
||||
if err := json.Unmarshal([]byte(value), &parsed); err == nil {
|
||||
parts = append(parts, parsed...)
|
||||
continue
|
||||
}
|
||||
}
|
||||
value = strings.Trim(value, "[]")
|
||||
for _, part := range strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == ',' || r == ';' || r == ';' || r == '\n'
|
||||
}) {
|
||||
part = strings.Trim(strings.TrimSpace(part), "\"'")
|
||||
if part != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func normalizeTimeframe(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
value = strings.Trim(value, "\"',,。 ")
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
aliases := map[string]string{
|
||||
"1分钟": "1m",
|
||||
"3分钟": "3m",
|
||||
"5分钟": "5m",
|
||||
"15分钟": "15m",
|
||||
"30分钟": "30m",
|
||||
"1小时": "1h",
|
||||
"2小时": "2h",
|
||||
"4小时": "4h",
|
||||
"6小时": "6h",
|
||||
"8小时": "8h",
|
||||
"12小时": "12h",
|
||||
"1天": "1d",
|
||||
"3天": "3d",
|
||||
"1周": "1w",
|
||||
}
|
||||
if alias, ok := aliases[value]; ok {
|
||||
return alias
|
||||
}
|
||||
allowed := map[string]bool{
|
||||
"1m": true, "3m": true, "5m": true, "15m": true, "30m": true,
|
||||
"1h": true, "2h": true, "4h": true, "6h": true, "8h": true, "12h": true,
|
||||
"1d": true, "3d": true, "1w": true,
|
||||
}
|
||||
if !allowed[value] {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// MergeStrategyConfig applies a partial JSON-style patch onto a full strategy config.
|
||||
// Nested objects are merged recursively so omitted fields keep their previous values.
|
||||
func MergeStrategyConfig(base StrategyConfig, patch map[string]any) (StrategyConfig, error) {
|
||||
baseJSON, err := json.Marshal(base)
|
||||
if err != nil {
|
||||
return StrategyConfig{}, err
|
||||
}
|
||||
|
||||
var mergedMap map[string]any
|
||||
if err := json.Unmarshal(baseJSON, &mergedMap); err != nil {
|
||||
return StrategyConfig{}, err
|
||||
}
|
||||
|
||||
normalizeStrategyConfigPatch(patch)
|
||||
if fmt.Sprint(patch["strategy_type"]) == "grid_trading" {
|
||||
ensureDefaultGridConfigMap(mergedMap)
|
||||
}
|
||||
mergeJSONMaps(mergedMap, patch)
|
||||
|
||||
mergedJSON, err := json.Marshal(mergedMap)
|
||||
if err != nil {
|
||||
return StrategyConfig{}, err
|
||||
}
|
||||
|
||||
var merged StrategyConfig
|
||||
if err := json.Unmarshal(mergedJSON, &merged); err != nil {
|
||||
return StrategyConfig{}, err
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func DefaultGridStrategyConfig() GridStrategyConfig {
|
||||
return GridStrategyConfig{
|
||||
Symbol: "BTCUSDT",
|
||||
GridCount: 10,
|
||||
TotalInvestment: 1000,
|
||||
Leverage: 5,
|
||||
UpperPrice: 0,
|
||||
LowerPrice: 0,
|
||||
UseATRBounds: true,
|
||||
ATRMultiplier: 2.0,
|
||||
Distribution: "gaussian",
|
||||
MaxDrawdownPct: 15,
|
||||
StopLossPct: 5,
|
||||
DailyLossLimitPct: 10,
|
||||
UseMakerOnly: true,
|
||||
EnableDirectionAdjust: false,
|
||||
DirectionBiasRatio: 0.7,
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDefaultGridConfigMap(config map[string]any) {
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
if existing, ok := config["grid_config"].(map[string]any); ok && len(existing) > 0 {
|
||||
return
|
||||
}
|
||||
defaultGrid := DefaultGridStrategyConfig()
|
||||
raw, err := json.Marshal(defaultGrid)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var gridMap map[string]any
|
||||
if err := json.Unmarshal(raw, &gridMap); err != nil {
|
||||
return
|
||||
}
|
||||
config["grid_config"] = gridMap
|
||||
}
|
||||
|
||||
func normalizeStrategyConfigPatch(patch map[string]any) {
|
||||
if patch == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if gridConfig, hasGrid := patch["grid_config"]; hasGrid && gridConfig != nil {
|
||||
if _, hasType := patch["strategy_type"]; !hasType {
|
||||
patch["strategy_type"] = "grid_trading"
|
||||
}
|
||||
}
|
||||
|
||||
aiKeys := []string{"coin_source", "indicators", "risk_control", "prompt_sections", "custom_prompt"}
|
||||
for _, key := range aiKeys {
|
||||
value, ok := patch[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
aiConfig, _ := patch["ai_config"].(map[string]any)
|
||||
if aiConfig == nil {
|
||||
aiConfig = map[string]any{}
|
||||
patch["ai_config"] = aiConfig
|
||||
}
|
||||
aiConfig[key] = value
|
||||
delete(patch, key)
|
||||
}
|
||||
|
||||
if fmt.Sprint(patch["strategy_type"]) == "grid_trading" {
|
||||
delete(patch, "ai_config")
|
||||
}
|
||||
|
||||
if _, hasType := patch["strategy_type"]; hasType {
|
||||
return
|
||||
}
|
||||
if gridConfig, hasGrid := patch["grid_config"]; hasGrid && gridConfig != nil {
|
||||
patch["strategy_type"] = "grid_trading"
|
||||
}
|
||||
}
|
||||
|
||||
func mergeJSONMaps(dst, src map[string]any) {
|
||||
for key, srcVal := range src {
|
||||
srcMap, srcIsMap := srcVal.(map[string]any)
|
||||
dstMap, dstIsMap := dst[key].(map[string]any)
|
||||
if srcIsMap && dstIsMap {
|
||||
mergeJSONMaps(dstMap, srcMap)
|
||||
continue
|
||||
}
|
||||
dst[key] = srcVal
|
||||
}
|
||||
}
|
||||
|
||||
func StrategyClampWarnings(before, after StrategyConfig, lang string) []string {
|
||||
if lang != "zh" {
|
||||
lang = "en"
|
||||
}
|
||||
warnings := make([]string, 0, 8)
|
||||
appendInt := func(labelZH, labelEN string, from, to int) {
|
||||
if from == to {
|
||||
return
|
||||
}
|
||||
if lang == "zh" {
|
||||
warnings = append(warnings, fmt.Sprintf("%s 已从 %d 调整为 %d", labelZH, from, to))
|
||||
return
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("%s adjusted from %d to %d", labelEN, from, to))
|
||||
}
|
||||
appendFloat := func(labelZH, labelEN string, from, to float64) {
|
||||
if from == to {
|
||||
return
|
||||
}
|
||||
if lang == "zh" {
|
||||
warnings = append(warnings, fmt.Sprintf("%s 已从 %.2f 调整为 %.2f", labelZH, from, to))
|
||||
return
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("%s adjusted from %.2f to %.2f", labelEN, from, to))
|
||||
}
|
||||
|
||||
appendInt("最大持仓数", "max_positions", before.RiskControl.MaxPositions, after.RiskControl.MaxPositions)
|
||||
appendInt("BTC/ETH 最大杠杆", "btc_eth_max_leverage", before.RiskControl.BTCETHMaxLeverage, after.RiskControl.BTCETHMaxLeverage)
|
||||
appendInt("山寨币最大杠杆", "altcoin_max_leverage", before.RiskControl.AltcoinMaxLeverage, after.RiskControl.AltcoinMaxLeverage)
|
||||
appendFloat("BTC/ETH 最大仓位价值倍数", "btc_eth_max_position_value_ratio", before.RiskControl.BTCETHMaxPositionValueRatio, after.RiskControl.BTCETHMaxPositionValueRatio)
|
||||
appendFloat("山寨币最大仓位价值倍数", "altcoin_max_position_value_ratio", before.RiskControl.AltcoinMaxPositionValueRatio, after.RiskControl.AltcoinMaxPositionValueRatio)
|
||||
appendFloat("最小盈亏比", "min_risk_reward_ratio", before.RiskControl.MinRiskRewardRatio, after.RiskControl.MinRiskRewardRatio)
|
||||
appendFloat("最大保证金使用率", "max_margin_usage", before.RiskControl.MaxMarginUsage, after.RiskControl.MaxMarginUsage)
|
||||
appendFloat("最小开仓金额", "min_position_size", before.RiskControl.MinPositionSize, after.RiskControl.MinPositionSize)
|
||||
appendInt("最低置信度", "min_confidence", before.RiskControl.MinConfidence, after.RiskControl.MinConfidence)
|
||||
return warnings
|
||||
}
|
||||
|
||||
// StrategyStore strategy storage
|
||||
@@ -90,19 +617,128 @@ type StrategyConfig struct {
|
||||
// language setting: "zh" for Chinese, "en" for English
|
||||
// This determines the language used for data formatting and prompt generation
|
||||
Language string `json:"language,omitempty"`
|
||||
// coin source configuration
|
||||
CoinSource CoinSourceConfig `json:"coin_source"`
|
||||
// quantitative data configuration
|
||||
Indicators IndicatorConfig `json:"indicators"`
|
||||
// custom prompt (appended at the end)
|
||||
CustomPrompt string `json:"custom_prompt,omitempty"`
|
||||
// risk control configuration
|
||||
RiskControl RiskControlConfig `json:"risk_control"`
|
||||
// editable sections of System Prompt
|
||||
PromptSections PromptSectionsConfig `json:"prompt_sections,omitempty"`
|
||||
// AI trading configuration fields are kept on the Go struct for engine
|
||||
// compatibility, but JSON persistence nests them under ai_config.
|
||||
CoinSource CoinSourceConfig `json:"-"`
|
||||
Indicators IndicatorConfig `json:"-"`
|
||||
CustomPrompt string `json:"-"`
|
||||
RiskControl RiskControlConfig `json:"-"`
|
||||
PromptSections PromptSectionsConfig `json:"-"`
|
||||
|
||||
// Grid trading configuration (only used when StrategyType == "grid_trading")
|
||||
GridConfig *GridStrategyConfig `json:"grid_config,omitempty"`
|
||||
|
||||
// Publish settings are shared by AI and grid strategies. The database still
|
||||
// stores the authoritative booleans on Strategy, but config JSON may carry
|
||||
// this object for agent/frontend schema consistency.
|
||||
PublishConfig *PublishStrategyConfig `json:"publish_config,omitempty"`
|
||||
}
|
||||
|
||||
// AIStrategyConfig contains fields only used by AI trading strategies.
|
||||
type AIStrategyConfig struct {
|
||||
CoinSource CoinSourceConfig `json:"coin_source"`
|
||||
Indicators IndicatorConfig `json:"indicators"`
|
||||
CustomPrompt string `json:"custom_prompt,omitempty"`
|
||||
RiskControl RiskControlConfig `json:"risk_control"`
|
||||
PromptSections PromptSectionsConfig `json:"prompt_sections,omitempty"`
|
||||
}
|
||||
|
||||
// PublishStrategyConfig contains settings shared by all strategy types.
|
||||
type PublishStrategyConfig struct {
|
||||
IsPublic bool `json:"is_public"`
|
||||
ConfigVisible bool `json:"config_visible"`
|
||||
}
|
||||
|
||||
// MarshalJSON writes the product-facing strategy schema:
|
||||
// strategy_type + grid_config or ai_config + shared publish_config.
|
||||
func (c StrategyConfig) MarshalJSON() ([]byte, error) {
|
||||
strategyType := strings.TrimSpace(c.StrategyType)
|
||||
if strategyType == "" {
|
||||
strategyType = "ai_trading"
|
||||
}
|
||||
|
||||
out := struct {
|
||||
StrategyType string `json:"strategy_type"`
|
||||
Language string `json:"language,omitempty"`
|
||||
AIConfig *AIStrategyConfig `json:"ai_config,omitempty"`
|
||||
GridConfig *GridStrategyConfig `json:"grid_config,omitempty"`
|
||||
PublishConfig *PublishStrategyConfig `json:"publish_config,omitempty"`
|
||||
}{
|
||||
StrategyType: strategyType,
|
||||
Language: c.Language,
|
||||
PublishConfig: c.PublishConfig,
|
||||
}
|
||||
|
||||
if strategyType == "grid_trading" {
|
||||
out.GridConfig = c.GridConfig
|
||||
} else {
|
||||
out.AIConfig = &AIStrategyConfig{
|
||||
CoinSource: c.CoinSource,
|
||||
Indicators: c.Indicators,
|
||||
CustomPrompt: c.CustomPrompt,
|
||||
RiskControl: c.RiskControl,
|
||||
PromptSections: c.PromptSections,
|
||||
}
|
||||
}
|
||||
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
// UnmarshalJSON accepts both the new nested schema and old flat configs. Old
|
||||
// top-level AI fields are normalized into the Go compatibility fields.
|
||||
func (c *StrategyConfig) UnmarshalJSON(data []byte) error {
|
||||
type rawStrategyConfig struct {
|
||||
StrategyType string `json:"strategy_type"`
|
||||
Language string `json:"language"`
|
||||
AIConfig *AIStrategyConfig `json:"ai_config"`
|
||||
GridConfig *GridStrategyConfig `json:"grid_config"`
|
||||
PublishConfig *PublishStrategyConfig `json:"publish_config"`
|
||||
|
||||
CoinSource *CoinSourceConfig `json:"coin_source"`
|
||||
Indicators *IndicatorConfig `json:"indicators"`
|
||||
CustomPrompt *string `json:"custom_prompt"`
|
||||
RiskControl *RiskControlConfig `json:"risk_control"`
|
||||
PromptSections *PromptSectionsConfig `json:"prompt_sections"`
|
||||
}
|
||||
|
||||
var raw rawStrategyConfig
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.StrategyType = raw.StrategyType
|
||||
c.Language = raw.Language
|
||||
c.GridConfig = raw.GridConfig
|
||||
c.PublishConfig = raw.PublishConfig
|
||||
|
||||
if raw.AIConfig != nil {
|
||||
c.CoinSource = raw.AIConfig.CoinSource
|
||||
c.Indicators = raw.AIConfig.Indicators
|
||||
c.CustomPrompt = raw.AIConfig.CustomPrompt
|
||||
c.RiskControl = raw.AIConfig.RiskControl
|
||||
c.PromptSections = raw.AIConfig.PromptSections
|
||||
} else {
|
||||
if raw.CoinSource != nil {
|
||||
c.CoinSource = *raw.CoinSource
|
||||
}
|
||||
if raw.Indicators != nil {
|
||||
c.Indicators = *raw.Indicators
|
||||
}
|
||||
if raw.CustomPrompt != nil {
|
||||
c.CustomPrompt = *raw.CustomPrompt
|
||||
}
|
||||
if raw.RiskControl != nil {
|
||||
c.RiskControl = *raw.RiskControl
|
||||
}
|
||||
if raw.PromptSections != nil {
|
||||
c.PromptSections = *raw.PromptSections
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(c.StrategyType) == "" && c.GridConfig != nil {
|
||||
c.StrategyType = "grid_trading"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GridStrategyConfig grid trading specific configuration
|
||||
@@ -153,7 +789,7 @@ type PromptSectionsConfig struct {
|
||||
|
||||
// CoinSourceConfig coin source configuration
|
||||
type CoinSourceConfig struct {
|
||||
// source type: "static" | "ai500" | "oi_top" | "oi_low" | "mixed"
|
||||
// source type shown in the product editor: "static" | "ai500" | "oi_top" | "oi_low"
|
||||
SourceType string `json:"source_type"`
|
||||
// static coin list (used when source_type = "static")
|
||||
StaticCoins []string `json:"static_coins,omitempty"`
|
||||
@@ -177,6 +813,20 @@ type CoinSourceConfig struct {
|
||||
UseHyperMain bool `json:"use_hyper_main"`
|
||||
// Hyperliquid Main maximum count (default 20)
|
||||
HyperMainLimit int `json:"hyper_main_limit,omitempty"`
|
||||
// Hyperliquid dynamic ranking category: stock, commodity, index, forex, pre_ipo, crypto, all
|
||||
HyperRankCategory string `json:"hyper_rank_category,omitempty"`
|
||||
// Hyperliquid dynamic ranking direction: gainers, losers, volume
|
||||
HyperRankDirection string `json:"hyper_rank_direction,omitempty"`
|
||||
// Hyperliquid dynamic ranking maximum count. Defaults to 5 and is hard capped at 10 for AI context safety.
|
||||
HyperRankLimit int `json:"hyper_rank_limit,omitempty"`
|
||||
// Vergex signal-ranking maximum count. Defaults to 5 and is hard capped at 10.
|
||||
VergexLimit int `json:"vergex_limit,omitempty"`
|
||||
// Vergex market type for detail endpoints, e.g. hip3_perp for Hyperliquid TradeFi perps.
|
||||
VergexMarketType string `json:"vergex_market_type,omitempty"`
|
||||
// Vergex chain query parameter. Defaults to hyperliquid.
|
||||
VergexChain string `json:"vergex_chain,omitempty"`
|
||||
// Vergex liquidation band query parameter.
|
||||
VergexLiqBand string `json:"vergex_liq_band,omitempty"`
|
||||
// Note: API URLs are now built automatically using NofxOSAPIKey from IndicatorConfig
|
||||
}
|
||||
|
||||
@@ -310,22 +960,29 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
config := StrategyConfig{
|
||||
Language: normalizedLang,
|
||||
CoinSource: CoinSourceConfig{
|
||||
SourceType: "ai500",
|
||||
UseAI500: true,
|
||||
AI500Limit: 3,
|
||||
UseOITop: false,
|
||||
OITopLimit: 3,
|
||||
UseOILow: false,
|
||||
OILowLimit: 3,
|
||||
SourceType: "vergex_signal",
|
||||
UseAI500: false,
|
||||
AI500Limit: 3,
|
||||
UseOITop: false,
|
||||
OITopLimit: 3,
|
||||
UseOILow: false,
|
||||
OILowLimit: 3,
|
||||
UseHyperAll: false,
|
||||
UseHyperMain: false,
|
||||
HyperMainLimit: 30,
|
||||
HyperRankCategory: "all",
|
||||
VergexLimit: 10,
|
||||
VergexMarketType: "all",
|
||||
VergexChain: "hyperliquid",
|
||||
},
|
||||
Indicators: IndicatorConfig{
|
||||
Klines: KlineConfig{
|
||||
PrimaryTimeframe: "5m",
|
||||
PrimaryCount: 20,
|
||||
LongerTimeframe: "4h",
|
||||
LongerCount: 10,
|
||||
EnableMultiTimeframe: true,
|
||||
SelectedTimeframes: []string{"5m", "15m", "1h"},
|
||||
PrimaryTimeframe: "15m",
|
||||
PrimaryCount: 30,
|
||||
LongerTimeframe: "",
|
||||
LongerCount: 0,
|
||||
EnableMultiTimeframe: false,
|
||||
SelectedTimeframes: []string{"15m"},
|
||||
},
|
||||
EnableRawKlines: true, // Required - raw OHLCV data for AI analysis
|
||||
EnableEMA: false,
|
||||
@@ -333,84 +990,81 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
EnableRSI: false,
|
||||
EnableATR: false,
|
||||
EnableBOLL: false,
|
||||
EnableVolume: true,
|
||||
EnableOI: true,
|
||||
EnableFundingRate: true,
|
||||
EnableVolume: false,
|
||||
EnableOI: false,
|
||||
EnableFundingRate: false,
|
||||
EMAPeriods: []int{20, 50},
|
||||
RSIPeriods: []int{7, 14},
|
||||
ATRPeriods: []int{14},
|
||||
BOLLPeriods: []int{20},
|
||||
// NofxOS unified API key
|
||||
NofxOSAPIKey: "cm_568c67eae410d912c54c",
|
||||
// Quant data
|
||||
EnableQuantData: true,
|
||||
EnableQuantOI: true,
|
||||
EnableQuantNetflow: true,
|
||||
// OI ranking data
|
||||
EnableOIRanking: true,
|
||||
OIRankingDuration: "1h",
|
||||
OIRankingLimit: 10,
|
||||
// NetFlow ranking data
|
||||
EnableNetFlowRanking: true,
|
||||
// Hyperliquid strategies must use native Hyperliquid market data by default.
|
||||
// NofxOS datasets do not cover all Hyperliquid XYZ assets, so keep them off.
|
||||
NofxOSAPIKey: "",
|
||||
EnableQuantData: false,
|
||||
EnableQuantOI: false,
|
||||
EnableQuantNetflow: false,
|
||||
EnableOIRanking: false,
|
||||
OIRankingDuration: "1h",
|
||||
OIRankingLimit: 10,
|
||||
EnableNetFlowRanking: false,
|
||||
NetFlowRankingDuration: "1h",
|
||||
NetFlowRankingLimit: 10,
|
||||
// Price ranking data
|
||||
EnablePriceRanking: true,
|
||||
PriceRankingDuration: "1h,4h,24h",
|
||||
PriceRankingLimit: 10,
|
||||
EnablePriceRanking: false,
|
||||
PriceRankingDuration: "1h,4h,24h",
|
||||
PriceRankingLimit: 10,
|
||||
},
|
||||
RiskControl: RiskControlConfig{
|
||||
MaxPositions: 3, // Max 3 coins simultaneously (CODE ENFORCED)
|
||||
BTCETHMaxLeverage: 5, // BTC/ETH exchange leverage (AI guided)
|
||||
AltcoinMaxLeverage: 5, // Altcoin exchange leverage (AI guided)
|
||||
BTCETHMaxPositionValueRatio: 5.0, // BTC/ETH: max position = 5x equity (CODE ENFORCED)
|
||||
AltcoinMaxPositionValueRatio: 1.0, // Altcoin: max position = 1x equity (CODE ENFORCED)
|
||||
MaxMarginUsage: 0.9, // Max 90% margin usage (CODE ENFORCED)
|
||||
MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED)
|
||||
MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided)
|
||||
MinConfidence: 75, // Min 75% confidence (AI guided)
|
||||
MaxPositions: 2, // Max 2 instruments simultaneously (CODE ENFORCED)
|
||||
BTCETHMaxLeverage: 10, // BTC/ETH exchange leverage (AI guided)
|
||||
AltcoinMaxLeverage: 10, // TradeFi exchange leverage (AI guided)
|
||||
BTCETHMaxPositionValueRatio: 10.0, // Claw402 full-size 10x notional: equity × 10
|
||||
AltcoinMaxPositionValueRatio: 10.0, // Claw402 full-size 10x notional: equity × 10
|
||||
MaxMarginUsage: 1.0, // Claw402 Autopilot intentionally uses full margin when opening
|
||||
MinPositionSize: 12, // Min 12 USDT per position (CODE ENFORCED)
|
||||
MinRiskRewardRatio: 3.0, // Min 3:1 profit/loss ratio (AI guided)
|
||||
MinConfidence: 78, // Min 78% confidence (AI guided)
|
||||
},
|
||||
}
|
||||
|
||||
if lang == "zh" {
|
||||
config.PromptSections = PromptSectionsConfig{
|
||||
RoleDefinition: `# 你是一个专业的加密货币交易AI
|
||||
RoleDefinition: `# 你是 NOFX Claw402 自动交易员
|
||||
|
||||
你的任务是根据提供的市场数据做出交易决策。你是一个经验丰富的量化交易员,擅长技术分析和风险管理。`,
|
||||
TradingFrequency: `# ⏱️ 交易频率意识
|
||||
你只交易 Claw402.ai/Vergex 本轮榜单返回的 Hyperliquid 可交易标的。候选池来自 Claw402.ai/Vergex,开仓前必须结合 Signal Lab、成本/清算热力图和原始 K 线判断。`,
|
||||
TradingFrequency: `# 交易频率
|
||||
|
||||
- 优秀交易员:每天2-4笔 ≈ 每小时0.1-0.2笔
|
||||
- 每小时超过2笔 = 过度交易
|
||||
- 单笔持仓时间 ≥ 30-60分钟
|
||||
如果你发现自己每个周期都在交易 → 标准太低;如果持仓不到30分钟就平仓 → 太冲动。`,
|
||||
EntryStandards: `# 🎯 入场标准(严格)
|
||||
- 优先等待高质量机会,不需要每轮都交易。
|
||||
- 先管理已有持仓,再考虑新开仓。
|
||||
- 同一轮不要频繁开平同一标的。`,
|
||||
EntryStandards: `# 入场标准
|
||||
|
||||
只在多个信号共振时入场。自由使用任何有效的分析方法,避免单一指标、信号矛盾、横盘震荡、或平仓后立即重新开仓等低质量行为。`,
|
||||
DecisionProcess: `# 📋 决策流程
|
||||
只有 Claw402 Signal Lab、成本/清算热力图和原始 K 线大体一致时才开仓。Claw402 排名只是候选池,不是单独买入理由。任一关键数据缺失或冲突时,默认等待。`,
|
||||
DecisionProcess: `# 决策流程
|
||||
|
||||
1. 检查持仓 → 是否止盈/止损
|
||||
2. 扫描候选币种 + 多时间框架 → 是否存在强信号
|
||||
3. 先写思维链,再输出结构化JSON`,
|
||||
1. 检查已有持仓,先决定止盈、止损或继续持有。
|
||||
2. 从 Claw402 榜单取本轮候选,并对每个候选读取 Claw402 Ranking、Signal Lab、Cost/Liquidation Heatmap。
|
||||
3. 用原始 K 线确认入场位置、止损和止盈。
|
||||
4. 输出简洁 reasoning 和严格 JSON。`,
|
||||
}
|
||||
} else {
|
||||
config.PromptSections = PromptSectionsConfig{
|
||||
RoleDefinition: `# You are a professional cryptocurrency trading AI
|
||||
RoleDefinition: `# You are the NOFX Claw402 auto-trader
|
||||
|
||||
Your task is to make trading decisions based on the provided market data. You are an experienced quantitative trader skilled in technical analysis and risk management.`,
|
||||
TradingFrequency: `# ⏱️ Trading Frequency Awareness
|
||||
Trade Hyperliquid Claw402-ranked instruments only. The candidate pool comes from Claw402.ai/Vergex; before opening a position, combine Signal Lab, cost/liquidation heatmap and raw candles.`,
|
||||
TradingFrequency: `# Trading Frequency
|
||||
|
||||
- Excellent trader: 2-4 trades per day ≈ 0.1-0.2 trades per hour
|
||||
- >2 trades per hour = overtrading
|
||||
- Single position holding time ≥ 30-60 minutes
|
||||
If you find yourself trading every cycle → standards are too low; if closing positions in <30 minutes → too impulsive.`,
|
||||
EntryStandards: `# 🎯 Entry Standards (Strict)
|
||||
- Wait for quality; you do not need to trade every cycle.
|
||||
- Manage existing positions before opening new ones.
|
||||
- Do not churn in and out of the same symbol in one cycle.`,
|
||||
EntryStandards: `# Entry Standards
|
||||
|
||||
Only enter positions when multiple signals resonate. Freely use any effective analysis methods, avoid low-quality behaviors such as single indicators, contradictory signals, sideways oscillation, or immediately restarting after closing positions.`,
|
||||
DecisionProcess: `# 📋 Decision Process
|
||||
Open only when Claw402 Signal Lab, cost/liquidation heatmap and raw candles broadly agree. Ranking defines the candidate pool, not a standalone entry reason. Wait when key data is missing or contradictory.`,
|
||||
DecisionProcess: `# Decision Process
|
||||
|
||||
1. Check positions → whether to take profit/stop loss
|
||||
2. Scan candidate coins + multi-timeframe → whether strong signals exist
|
||||
3. Write chain of thought first, then output structured JSON`,
|
||||
1. Check current positions first: take profit, stop loss or hold.
|
||||
2. Pull this cycle's Claw402 board and read Claw402 Ranking, Signal Lab and Cost/Liquidation Heatmap for each candidate.
|
||||
3. Use raw candles to confirm entry, stop and target.
|
||||
4. Output concise reasoning and strict JSON.`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +1098,9 @@ func (s *StrategyStore) Delete(userID, id string) error {
|
||||
if st.IsDefault {
|
||||
return fmt.Errorf("cannot delete system default strategy")
|
||||
}
|
||||
if st.IsActive {
|
||||
return fmt.Errorf("cannot delete active strategy")
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any trader references this strategy
|
||||
@@ -847,18 +1504,16 @@ func (c *StrategyConfig) getEffectiveCoinCount() int {
|
||||
count = c.CoinSource.OITopLimit
|
||||
case "oi_low":
|
||||
count = c.CoinSource.OILowLimit
|
||||
case "mixed":
|
||||
if c.CoinSource.UseAI500 {
|
||||
count += c.CoinSource.AI500Limit
|
||||
}
|
||||
if c.CoinSource.UseOITop {
|
||||
count += c.CoinSource.OITopLimit
|
||||
}
|
||||
if c.CoinSource.UseOILow {
|
||||
count += c.CoinSource.OILowLimit
|
||||
}
|
||||
case "hyper_rank":
|
||||
count = c.CoinSource.HyperRankLimit
|
||||
case "vergex_signal":
|
||||
count = c.CoinSource.VergexLimit
|
||||
case "hyper_main":
|
||||
count = c.CoinSource.HyperMainLimit
|
||||
case "hyper_all":
|
||||
count = c.CoinSource.HyperMainLimit
|
||||
default:
|
||||
count = c.CoinSource.AI500Limit
|
||||
count = c.CoinSource.HyperRankLimit
|
||||
}
|
||||
if count <= 0 {
|
||||
count = 3
|
||||
|
||||
42
store/strategy_hyperliquid_defaults_test.go
Normal file
42
store/strategy_hyperliquid_defaults_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultVergexStrategyDoesNotEnableNofxOSData(t *testing.T) {
|
||||
cfg := GetDefaultStrategyConfig("zh")
|
||||
assertVergexSignalDefault(t, cfg)
|
||||
ind := cfg.Indicators
|
||||
if ind.NofxOSAPIKey != "" {
|
||||
t.Fatalf("default should not include a NofxOS API key for Claw402/Vergex strategies")
|
||||
}
|
||||
if ind.EnableQuantData || ind.EnableQuantOI || ind.EnableQuantNetflow || ind.EnableOIRanking || ind.EnableNetFlowRanking || ind.EnablePriceRanking {
|
||||
t.Fatalf("default Claw402/Vergex strategy must not enable NofxOS datasets: %+v", ind)
|
||||
}
|
||||
if !ind.EnableRawKlines {
|
||||
t.Fatalf("raw Hyperliquid klines must stay enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVergexSignalDefaultSurvivesClampAndNormalize(t *testing.T) {
|
||||
cfg := GetDefaultStrategyConfig("zh")
|
||||
cfg.CoinSource.UseAI500 = true
|
||||
cfg.ClampLimits()
|
||||
assertVergexSignalDefault(t, cfg)
|
||||
if cfg.CoinSource.UseAI500 {
|
||||
t.Fatalf("Claw402/Vergex signal strategy must clear stale AI500 flag: %+v", cfg.CoinSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyCoinSourceInfersVergexSignalNotAI500(t *testing.T) {
|
||||
cfg := GetDefaultStrategyConfig("zh")
|
||||
cfg.CoinSource = CoinSourceConfig{}
|
||||
cfg.NormalizeProductSchema()
|
||||
assertVergexSignalDefault(t, cfg)
|
||||
}
|
||||
|
||||
func assertVergexSignalDefault(t *testing.T, cfg StrategyConfig) {
|
||||
t.Helper()
|
||||
if cfg.CoinSource.SourceType != "vergex_signal" || cfg.CoinSource.VergexLimit != 10 || cfg.CoinSource.VergexMarketType != "all" || cfg.CoinSource.VergexChain != "hyperliquid" {
|
||||
t.Fatalf("coin source = %+v, want Claw402/Vergex all-market signal top 10", cfg.CoinSource)
|
||||
}
|
||||
}
|
||||
183
store/strategy_schema_test.go
Normal file
183
store/strategy_schema_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStrategyConfigMarshalSeparatesGridAndAIConfig(t *testing.T) {
|
||||
cfg := GetDefaultStrategyConfig("zh")
|
||||
cfg.StrategyType = "grid_trading"
|
||||
cfg.GridConfig = &GridStrategyConfig{
|
||||
Symbol: "BTCUSDT",
|
||||
GridCount: 20,
|
||||
TotalInvestment: 200,
|
||||
Leverage: 2,
|
||||
UseATRBounds: true,
|
||||
ATRMultiplier: 2,
|
||||
Distribution: "uniform",
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal grid config: %v", err)
|
||||
}
|
||||
|
||||
var asMap map[string]any
|
||||
if err := json.Unmarshal(raw, &asMap); err != nil {
|
||||
t.Fatalf("unmarshal grid config map: %v", err)
|
||||
}
|
||||
if asMap["strategy_type"] != "grid_trading" {
|
||||
t.Fatalf("expected grid strategy_type, got %v", asMap["strategy_type"])
|
||||
}
|
||||
if _, ok := asMap["grid_config"]; !ok {
|
||||
t.Fatalf("expected grid_config in grid strategy JSON: %s", string(raw))
|
||||
}
|
||||
for _, key := range []string{"ai_config", "coin_source", "indicators", "risk_control", "prompt_sections", "custom_prompt"} {
|
||||
if _, ok := asMap[key]; ok {
|
||||
t.Fatalf("did not expect %s in grid strategy JSON: %s", key, string(raw))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyConfigUnmarshalLegacyFlatAIConfig(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"strategy_type":"ai_trading",
|
||||
"coin_source":{"source_type":"static","static_coins":["ETHUSDT"]},
|
||||
"indicators":{"klines":{"primary_timeframe":"15m"}},
|
||||
"risk_control":{"max_positions":2,"min_confidence":80},
|
||||
"prompt_sections":{"entry_standards":"trend only"},
|
||||
"custom_prompt":"prefer ETH"
|
||||
}`)
|
||||
|
||||
var cfg StrategyConfig
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
t.Fatalf("unmarshal legacy flat config: %v", err)
|
||||
}
|
||||
if cfg.CoinSource.SourceType != "static" || len(cfg.CoinSource.StaticCoins) != 1 || cfg.CoinSource.StaticCoins[0] != "ETHUSDT" {
|
||||
t.Fatalf("legacy coin source was not normalized: %+v", cfg.CoinSource)
|
||||
}
|
||||
if cfg.Indicators.Klines.PrimaryTimeframe != "15m" {
|
||||
t.Fatalf("legacy indicators were not normalized: %+v", cfg.Indicators.Klines)
|
||||
}
|
||||
|
||||
normalized, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal normalized config: %v", err)
|
||||
}
|
||||
var asMap map[string]any
|
||||
if err := json.Unmarshal(normalized, &asMap); err != nil {
|
||||
t.Fatalf("unmarshal normalized map: %v", err)
|
||||
}
|
||||
if _, ok := asMap["ai_config"]; !ok {
|
||||
t.Fatalf("expected ai_config after normalizing legacy config: %s", string(normalized))
|
||||
}
|
||||
if _, ok := asMap["coin_source"]; ok {
|
||||
t.Fatalf("did not expect legacy coin_source at top level: %s", string(normalized))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyConfigNormalizeProductSchemaForLLMLabels(t *testing.T) {
|
||||
cfg := GetDefaultStrategyConfig("zh")
|
||||
patch := map[string]any{
|
||||
"strategy_type": "AI 策略",
|
||||
"ai_config": map[string]any{
|
||||
"coin_source": map[string]any{
|
||||
"source_type": "AI500",
|
||||
},
|
||||
"indicators": map[string]any{
|
||||
"klines": map[string]any{
|
||||
"primary_timeframe": "1分钟",
|
||||
"selected_timeframes": []any{`["1m"`, `"5m"`, `"15m"]`},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
merged, err := MergeStrategyConfig(cfg, patch)
|
||||
if err != nil {
|
||||
t.Fatalf("merge strategy config: %v", err)
|
||||
}
|
||||
merged.ClampLimits()
|
||||
|
||||
if merged.StrategyType != "ai_trading" {
|
||||
t.Fatalf("strategy_type = %q, want ai_trading", merged.StrategyType)
|
||||
}
|
||||
if merged.CoinSource.SourceType != "ai500" {
|
||||
t.Fatalf("source_type = %q, want ai500", merged.CoinSource.SourceType)
|
||||
}
|
||||
if !merged.CoinSource.UseAI500 || merged.CoinSource.UseOITop || merged.CoinSource.UseOILow {
|
||||
t.Fatalf("coin source flags not normalized: %+v", merged.CoinSource)
|
||||
}
|
||||
if merged.Indicators.Klines.PrimaryTimeframe != "1m" {
|
||||
t.Fatalf("primary_timeframe = %q, want 1m", merged.Indicators.Klines.PrimaryTimeframe)
|
||||
}
|
||||
want := []string{"1m", "5m", "15m"}
|
||||
if len(merged.Indicators.Klines.SelectedTimeframes) != len(want) {
|
||||
t.Fatalf("selected_timeframes = %+v, want %+v", merged.Indicators.Klines.SelectedTimeframes, want)
|
||||
}
|
||||
for i := range want {
|
||||
if merged.Indicators.Klines.SelectedTimeframes[i] != want[i] {
|
||||
t.Fatalf("selected_timeframes = %+v, want %+v", merged.Indicators.Klines.SelectedTimeframes, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyConfigNormalizeProductSchemaForVergexSignal(t *testing.T) {
|
||||
cfg := GetDefaultStrategyConfig("zh")
|
||||
cfg.CoinSource = CoinSourceConfig{
|
||||
SourceType: "Claw402 Vergex 信号榜",
|
||||
}
|
||||
|
||||
cfg.NormalizeProductSchema()
|
||||
|
||||
if cfg.CoinSource.SourceType != "vergex_signal" {
|
||||
t.Fatalf("source_type = %q, want vergex_signal", cfg.CoinSource.SourceType)
|
||||
}
|
||||
if cfg.CoinSource.VergexLimit != 10 {
|
||||
t.Fatalf("vergex_limit = %d, want 10", cfg.CoinSource.VergexLimit)
|
||||
}
|
||||
if cfg.CoinSource.VergexMarketType != "all" {
|
||||
t.Fatalf("vergex_market_type = %q, want all", cfg.CoinSource.VergexMarketType)
|
||||
}
|
||||
if cfg.CoinSource.VergexChain != "hyperliquid" {
|
||||
t.Fatalf("vergex_chain = %q, want hyperliquid", cfg.CoinSource.VergexChain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrategyConfigNormalizeProductSchemaForVergexSignalLimits(t *testing.T) {
|
||||
t.Run("dynamic board keeps the one built-in strategy candidate depth", func(t *testing.T) {
|
||||
cfg := GetDefaultStrategyConfig("zh")
|
||||
cfg.CoinSource = CoinSourceConfig{
|
||||
SourceType: "vergex_signal",
|
||||
VergexLimit: 1,
|
||||
StaticCoins: nil,
|
||||
VergexChain: "hyperliquid",
|
||||
VergexLiqBand: "",
|
||||
}
|
||||
|
||||
cfg.NormalizeProductSchema()
|
||||
|
||||
if cfg.CoinSource.VergexLimit != 10 {
|
||||
t.Fatalf("vergex_limit = %d, want 10", cfg.CoinSource.VergexLimit)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("manual picks keep selected count", func(t *testing.T) {
|
||||
cfg := GetDefaultStrategyConfig("zh")
|
||||
cfg.CoinSource = CoinSourceConfig{
|
||||
SourceType: "vergex_signal",
|
||||
VergexLimit: 1,
|
||||
StaticCoins: []string{"xyz:nvda", "XYZ:AAPL"},
|
||||
}
|
||||
|
||||
cfg.NormalizeProductSchema()
|
||||
|
||||
if cfg.CoinSource.VergexLimit != 2 {
|
||||
t.Fatalf("vergex_limit = %d, want 2", cfg.CoinSource.VergexLimit)
|
||||
}
|
||||
if got := cfg.CoinSource.StaticCoins; len(got) != 2 || got[0] != "XYZ:NVDA" || got[1] != "XYZ:AAPL" {
|
||||
t.Fatalf("static_coins = %+v, want normalized xyz symbols", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -26,7 +26,7 @@ type Trader struct {
|
||||
ExchangeID string `gorm:"column:exchange_id;not null" json:"exchange_id"`
|
||||
StrategyID string `gorm:"column:strategy_id;default:''" json:"strategy_id"`
|
||||
InitialBalance float64 `gorm:"column:initial_balance;not null" json:"initial_balance"`
|
||||
ScanIntervalMinutes int `gorm:"column:scan_interval_minutes;default:3" json:"scan_interval_minutes"`
|
||||
ScanIntervalMinutes int `gorm:"column:scan_interval_minutes;default:15" json:"scan_interval_minutes"`
|
||||
IsRunning bool `gorm:"column:is_running;default:false" json:"is_running"`
|
||||
IsCrossMargin bool `gorm:"column:is_cross_margin;default:true" json:"is_cross_margin"`
|
||||
ShowInCompetition bool `gorm:"column:show_in_competition;default:true" json:"show_in_competition"`
|
||||
@@ -110,12 +110,20 @@ func (s *TraderStore) Update(trader *Trader) error {
|
||||
trader.ID, trader.Name, trader.AIModelID, trader.StrategyID)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"name": trader.Name,
|
||||
"ai_model_id": trader.AIModelID,
|
||||
"exchange_id": trader.ExchangeID,
|
||||
"strategy_id": trader.StrategyID,
|
||||
"is_cross_margin": trader.IsCrossMargin,
|
||||
"show_in_competition": trader.ShowInCompetition,
|
||||
"name": trader.Name,
|
||||
"ai_model_id": trader.AIModelID,
|
||||
"exchange_id": trader.ExchangeID,
|
||||
"strategy_id": trader.StrategyID,
|
||||
"is_cross_margin": trader.IsCrossMargin,
|
||||
"show_in_competition": trader.ShowInCompetition,
|
||||
"btc_eth_leverage": trader.BTCETHLeverage,
|
||||
"altcoin_leverage": trader.AltcoinLeverage,
|
||||
"trading_symbols": trader.TradingSymbols,
|
||||
"use_coin_pool": trader.UseAI500,
|
||||
"use_oi_top": trader.UseOITop,
|
||||
"custom_prompt": trader.CustomPrompt,
|
||||
"override_base_prompt": trader.OverrideBasePrompt,
|
||||
"system_prompt_template": trader.SystemPromptTemplate,
|
||||
}
|
||||
|
||||
// Only update these if > 0
|
||||
|
||||
96
store/visibility.go
Normal file
96
store/visibility.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package store
|
||||
|
||||
import "strings"
|
||||
|
||||
func MissingRequiredExchangeCredentialFields(exchangeType, apiKey, secretKey, passphrase, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterAPIKeyPrivateKey string) []string {
|
||||
switch strings.ToLower(strings.TrimSpace(exchangeType)) {
|
||||
case "binance", "bybit", "gate", "indodax":
|
||||
return missingNamedFields(
|
||||
namedField{"api_key", apiKey},
|
||||
namedField{"secret_key", secretKey},
|
||||
)
|
||||
case "okx", "bitget", "kucoin":
|
||||
return missingNamedFields(
|
||||
namedField{"api_key", apiKey},
|
||||
namedField{"secret_key", secretKey},
|
||||
namedField{"passphrase", passphrase},
|
||||
)
|
||||
case "hyperliquid":
|
||||
return missingNamedFields(
|
||||
namedField{"api_key", apiKey},
|
||||
namedField{"hyperliquid_wallet_addr", hyperliquidWalletAddr},
|
||||
)
|
||||
case "aster":
|
||||
return missingNamedFields(
|
||||
namedField{"aster_user", asterUser},
|
||||
namedField{"aster_signer", asterSigner},
|
||||
namedField{"aster_private_key", asterPrivateKey},
|
||||
)
|
||||
case "lighter":
|
||||
return missingNamedFields(
|
||||
namedField{"lighter_wallet_addr", lighterWalletAddr},
|
||||
namedField{"lighter_api_key_private_key", lighterAPIKeyPrivateKey},
|
||||
)
|
||||
default:
|
||||
return []string{"exchange_type"}
|
||||
}
|
||||
}
|
||||
|
||||
type namedField struct {
|
||||
name string
|
||||
value string
|
||||
}
|
||||
|
||||
func missingNamedFields(fields ...namedField) []string {
|
||||
missing := make([]string, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
if strings.TrimSpace(field.value) == "" {
|
||||
missing = append(missing, field.name)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func IsVisibleAIModel(model *AIModel) bool {
|
||||
if model == nil {
|
||||
return false
|
||||
}
|
||||
return model.Enabled ||
|
||||
strings.TrimSpace(string(model.APIKey)) != "" ||
|
||||
strings.TrimSpace(model.CustomAPIURL) != "" ||
|
||||
strings.TrimSpace(model.CustomModelName) != ""
|
||||
}
|
||||
|
||||
func IsVisibleExchange(exchange *Exchange) bool {
|
||||
if exchange == nil {
|
||||
return false
|
||||
}
|
||||
return exchange.Enabled ||
|
||||
strings.TrimSpace(string(exchange.APIKey)) != "" ||
|
||||
strings.TrimSpace(string(exchange.SecretKey)) != "" ||
|
||||
strings.TrimSpace(string(exchange.Passphrase)) != "" ||
|
||||
strings.TrimSpace(exchange.HyperliquidWalletAddr) != "" ||
|
||||
strings.TrimSpace(exchange.AsterUser) != "" ||
|
||||
strings.TrimSpace(exchange.AsterSigner) != "" ||
|
||||
strings.TrimSpace(string(exchange.AsterPrivateKey)) != "" ||
|
||||
strings.TrimSpace(exchange.LighterWalletAddr) != "" ||
|
||||
strings.TrimSpace(string(exchange.LighterPrivateKey)) != "" ||
|
||||
strings.TrimSpace(string(exchange.LighterAPIKeyPrivateKey)) != "" ||
|
||||
exchange.LighterAPIKeyIndex != 0
|
||||
}
|
||||
|
||||
func IsVisibleTrader(trader *Trader) bool {
|
||||
if trader == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(trader.Name) != "" &&
|
||||
strings.TrimSpace(trader.AIModelID) != "" &&
|
||||
strings.TrimSpace(trader.ExchangeID) != ""
|
||||
}
|
||||
|
||||
func IsVisibleStrategy(strategy *Strategy) bool {
|
||||
if strategy == nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(strategy.Name) != ""
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -26,6 +27,102 @@ type apiRequest struct {
|
||||
Body map[string]any `json:"body"`
|
||||
}
|
||||
|
||||
// allowedRoute is one entry in the LLM tool allowlist. The bot agent runs with
|
||||
// a real user JWT, so we MUST default-deny: any path not listed here is rejected
|
||||
// before the HTTP call is made. This prevents prompt-injection (via account
|
||||
// names, strategy names, etc. injected into the LLM context) from coercing the
|
||||
// bot into changing the user's password, swapping exchange credentials, or
|
||||
// pointing the LLM API key at an attacker-controlled URL.
|
||||
type allowedRoute struct {
|
||||
method string
|
||||
pattern *regexp.Regexp
|
||||
}
|
||||
|
||||
// botAPIAllowlist enumerates the endpoints the Telegram LLM agent is permitted
|
||||
// to call. Keep this LIST SHORT and DEFAULT-DENY. To grant the bot access to a
|
||||
// new endpoint, add an explicit entry here — never widen wildcards.
|
||||
//
|
||||
// Explicitly NOT allowed (and must never be added without a human-in-the-loop
|
||||
// confirmation flow):
|
||||
// - PUT /api/user/password (password takeover)
|
||||
// - PUT /api/models (LLM API key + endpoint swap → exfil)
|
||||
// - POST/PUT/DELETE /api/exchanges* (exchange credential swap → drain)
|
||||
// - account recovery (password reset / account wipe) is intentionally
|
||||
// CLI-only (`nofx reset-password` / `nofx reset-account`) and has no
|
||||
// HTTP endpoint, so the bot cannot reach it
|
||||
// - POST /api/wallet/generate, /api/wallet/validate
|
||||
// - POST /api/telegram/* (rebind bot)
|
||||
var botAPIAllowlist = []allowedRoute{
|
||||
// Read-only endpoints that surface state to the user.
|
||||
{"GET", regexp.MustCompile(`^/api/health$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/config$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/supported-models$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/supported-exchanges$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/models$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/exchanges$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/exchanges/account-state$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/strategies(/[^/]+)?$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/strategies/active$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/strategies/default-config$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/strategies/public$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/my-traders$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/traders$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/traders/[^/]+/config$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/traders/[^/]+/public-config$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/traders/[^/]+/grid-risk$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/competition$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/top-traders$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/equity-history$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/klines$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/symbols$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/status$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/account$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/positions$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/positions/history$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/trades$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/orders$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/orders/[^/]+/fills$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/open-orders$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/decisions$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/decisions/latest$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/statistics$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/ai-costs$`)},
|
||||
{"GET", regexp.MustCompile(`^/api/ai-costs/summary$`)},
|
||||
|
||||
// Write endpoints — trader and strategy lifecycle. These let the bot create
|
||||
// traders and strategies the user has asked for, and start/stop them. NOT
|
||||
// including any endpoint that mutates credentials, passwords, or pointers
|
||||
// to external services (LLM API URL, exchange API keys, telegram binding).
|
||||
// Strategy configs are server-side-validated for risk caps in the API
|
||||
// layer, so strategy create/update here cannot escape the user's risk
|
||||
// boundary.
|
||||
{"POST", regexp.MustCompile(`^/api/traders$`)},
|
||||
{"PUT", regexp.MustCompile(`^/api/traders/[^/]+$`)},
|
||||
{"DELETE", regexp.MustCompile(`^/api/traders/[^/]+$`)},
|
||||
{"POST", regexp.MustCompile(`^/api/traders/[^/]+/start$`)},
|
||||
{"POST", regexp.MustCompile(`^/api/traders/[^/]+/stop$`)},
|
||||
{"POST", regexp.MustCompile(`^/api/traders/[^/]+/sync-balance$`)},
|
||||
{"POST", regexp.MustCompile(`^/api/traders/[^/]+/close-position$`)},
|
||||
{"PUT", regexp.MustCompile(`^/api/traders/[^/]+/prompt$`)},
|
||||
{"PUT", regexp.MustCompile(`^/api/traders/[^/]+/competition$`)},
|
||||
{"POST", regexp.MustCompile(`^/api/strategies$`)},
|
||||
{"PUT", regexp.MustCompile(`^/api/strategies/[^/]+$`)},
|
||||
{"DELETE", regexp.MustCompile(`^/api/strategies/[^/]+$`)},
|
||||
{"POST", regexp.MustCompile(`^/api/strategies/[^/]+/activate$`)},
|
||||
{"POST", regexp.MustCompile(`^/api/strategies/[^/]+/duplicate$`)},
|
||||
}
|
||||
|
||||
// isPathAllowed returns true when the (method, path) pair is in botAPIAllowlist.
|
||||
// The path argument should already be query-stripped.
|
||||
func isPathAllowed(method, path string) bool {
|
||||
for _, r := range botAPIAllowlist {
|
||||
if r.method == method && r.pattern.MatchString(path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newAPICallTool(port int, token string) *apiCallTool {
|
||||
return &apiCallTool{
|
||||
baseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
|
||||
@@ -43,6 +140,23 @@ func (t *apiCallTool) execute(req *apiRequest) string {
|
||||
req.Path = "/" + req.Path
|
||||
}
|
||||
|
||||
// SECURITY: default-deny allowlist enforcement. Without this, prompt
|
||||
// injection via user-controlled fields (account_name, strategy name,
|
||||
// trader name) could coerce the LLM into calling sensitive endpoints
|
||||
// like PUT /api/user/password or PUT /api/exchanges with the bot's JWT.
|
||||
method := strings.ToUpper(req.Method)
|
||||
pathOnly := req.Path
|
||||
if i := strings.IndexByte(pathOnly, '?'); i >= 0 {
|
||||
pathOnly = pathOnly[:i]
|
||||
}
|
||||
if !isPathAllowed(method, pathOnly) {
|
||||
logger.Warnf("Agent: blocked disallowed tool call %s %s (path not in botAPIAllowlist)", method, pathOnly)
|
||||
return fmt.Sprintf(
|
||||
`{"error":"endpoint not allowed for the chat agent","method":%q,"path":%q,"hint":"ask the user to perform this action in the web UI"}`,
|
||||
method, pathOnly,
|
||||
)
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
if req.Method != "GET" && len(req.Body) > 0 {
|
||||
b, err := json.Marshal(req.Body)
|
||||
@@ -85,4 +199,3 @@ func (t *apiCallTool) execute(req *apiRequest) string {
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,14 +36,21 @@ func (t *AsterTrader) GetBalance() (map[string]interface{}, error) {
|
||||
foundUSDT = true
|
||||
|
||||
// Parse Aster fields (reference: https://github.com/asterdex/api-docs)
|
||||
var parseErr error
|
||||
if avail, ok := bal["availableBalance"].(string); ok {
|
||||
availableBalance, _ = strconv.ParseFloat(avail, 64)
|
||||
if availableBalance, parseErr = types.ParseFloatField("availableBalance", avail); parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
}
|
||||
if unpnl, ok := bal["crossUnPnl"].(string); ok {
|
||||
crossUnPnl, _ = strconv.ParseFloat(unpnl, 64)
|
||||
if crossUnPnl, parseErr = types.ParseFloatField("crossUnPnl", unpnl); parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
}
|
||||
if cwb, ok := bal["crossWalletBalance"].(string); ok {
|
||||
crossWalletBalance, _ = strconv.ParseFloat(cwb, 64)
|
||||
if crossWalletBalance, parseErr = types.ParseFloatField("crossWalletBalance", cwb); parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -9,6 +9,14 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Aggressive limit prices simulate market orders: buy slightly above and sell
|
||||
// slightly below the current price so limit orders fill immediately while
|
||||
// capping slippage at 1%.
|
||||
const (
|
||||
aggressiveBuyPriceFactor = 1.01
|
||||
aggressiveSellPriceFactor = 0.99
|
||||
)
|
||||
|
||||
// OpenLong Open long position
|
||||
func (t *AsterTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
// Cancel all pending orders before opening position to prevent position stacking from residual orders
|
||||
@@ -34,7 +42,7 @@ func (t *AsterTrader) OpenLong(symbol string, quantity float64, leverage int) (m
|
||||
}
|
||||
|
||||
// Use limit order to simulate market order (price set slightly higher to ensure execution)
|
||||
limitPrice := price * 1.01
|
||||
limitPrice := price * aggressiveBuyPriceFactor
|
||||
|
||||
// Format price and quantity to correct precision
|
||||
formattedPrice, err := t.formatPrice(symbol, limitPrice)
|
||||
@@ -107,7 +115,7 @@ func (t *AsterTrader) OpenShort(symbol string, quantity float64, leverage int) (
|
||||
}
|
||||
|
||||
// Use limit order to simulate market order (price set slightly lower to ensure execution)
|
||||
limitPrice := price * 0.99
|
||||
limitPrice := price * aggressiveSellPriceFactor
|
||||
|
||||
// Format price and quantity to correct precision
|
||||
formattedPrice, err := t.formatPrice(symbol, limitPrice)
|
||||
@@ -182,7 +190,7 @@ func (t *AsterTrader) CloseLong(symbol string, quantity float64) (map[string]int
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limitPrice := price * 0.99
|
||||
limitPrice := price * aggressiveSellPriceFactor
|
||||
|
||||
// Format price and quantity to correct precision
|
||||
formattedPrice, err := t.formatPrice(symbol, limitPrice)
|
||||
@@ -265,7 +273,7 @@ func (t *AsterTrader) CloseShort(symbol string, quantity float64) (map[string]in
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limitPrice := price * 1.01
|
||||
limitPrice := price * aggressiveBuyPriceFactor
|
||||
|
||||
// Format price and quantity to correct precision
|
||||
formattedPrice, err := t.formatPrice(symbol, limitPrice)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user