mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-22 03:37:36 +08:00
Compare commits
29 Commits
a4983d2cb0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39eac5aca7 | ||
|
|
0f3e71560c | ||
|
|
eabd279d10 | ||
|
|
09b7ac9e92 | ||
|
|
dc68884559 | ||
|
|
21407030ea | ||
|
|
7a66d048f3 | ||
|
|
8c8cd9b61f | ||
|
|
d84e22ab82 | ||
|
|
221cf3d61d | ||
|
|
390db72cdb | ||
|
|
c1b660bb45 | ||
|
|
c53a563bff | ||
|
|
e593874912 | ||
|
|
e9873b5b86 | ||
|
|
026c5d3089 | ||
|
|
ee5917adc6 | ||
|
|
9b70455e7f | ||
|
|
a744328313 | ||
|
|
b17d97effc | ||
|
|
e61f12d307 | ||
|
|
30f3feb586 | ||
|
|
49613830ce | ||
|
|
ea74f91de2 | ||
|
|
a6884998b3 | ||
|
|
277b5ad9c6 | ||
|
|
110bf52908 | ||
|
|
eba28bcf0e | ||
|
|
c4e79d9579 |
333
README.md
333
README.md
@@ -1,22 +1,14 @@
|
||||
<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>AI trading terminal for global markets.</strong><br/>
|
||||
<strong>Research, strategy generation, execution, and monitoring for US stocks, commodities, forex, and crypto.</strong>
|
||||
<img src="docs/assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,23 +21,29 @@
|
||||
<a href="docs/i18n/vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
NOFX is an open-source AI trading terminal for active traders who want one workspace for market research, strategy development, execution, and portfolio monitoring.
|
||||
NOFX is an open-source trading terminal where the strategy is a language model. Each trader runs a continuous loop — read market structure, decide, execute, record the reasoning — while a Go runtime clamps every order to hard risk limits the model cannot override.
|
||||
|
||||
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.
|
||||
Traders compose freely: any model, any of nine exchanges, any strategy. Run several side by side and compare them on a public leaderboard by realized return. Everything runs on your own machine; exchange credentials are encrypted at rest and never leave it.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
Open **http://127.0.0.1:3000**.
|
||||
The terminal opens at `http://127.0.0.1:3000`.
|
||||
|
||||
---
|
||||
**First run**
|
||||
|
||||
1. Register — the first account becomes the owner of the instance.
|
||||
2. Follow the guided launch: put **$1+ USDC** (Base network) in the AI fee wallet it creates for you, then connect Hyperliquid and deposit **$12+ USDC** to trade with.
|
||||
3. Start **Autopilot**. The AI scans the market every few minutes and trades on its own; every decision appears on the dashboard as it happens. Stop it anytime with one click.
|
||||
|
||||
<br/>
|
||||
|
||||
## Register exchanges
|
||||
|
||||
Use the links below to open trading accounts for crypto and supported US stock, FX, and commodity derivative markets. These routes are part of NOFX partner programs and may include fee discounts or referral benefits.
|
||||
NOFX is free and open source. Opening an account through the partner links below carries reduced trading fees and funds continued development.
|
||||
|
||||
| Exchange | Status | Register with fee discount |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
@@ -59,67 +57,47 @@ Use the links below to open trading accounts for crypto and supported US stock,
|
||||
| <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) |
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Quick demo
|
||||
## Demo
|
||||
|
||||
<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>
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
<p align="center">
|
||||
Click the cover image to watch the demo video.
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
---
|
||||
## The model proposes. The runtime disposes.
|
||||
|
||||
## Markets
|
||||
Decisions come from a language model reading the [Claw402.ai](https://claw402.ai) · Vergex data stack: a live signal board that ranks every market with directional bias and signal strength, per-symbol Signal Lab deep signals, cost-basis and liquidation heatmaps that show where the crowd's fuel and walls sit, and real-time market net flow — cross-checked against raw candles and the trader's own live track record. Execution does not.
|
||||
|
||||
**US Stocks · Commodities · Forex · Crypto**
|
||||
Every order passes through limits enforced in code, outside the model's reach:
|
||||
|
||||
NOFX organizes research, strategy construction, execution, and monitoring around multi-asset workflows instead of single-venue screens.
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| Position limits | Max concurrent positions, notional capped as a ratio of equity, one position per symbol |
|
||||
| Leverage clamps | Hard caps applied at order-sizing time, independent of what the model requests |
|
||||
| Exchange-side protection | Stop-loss and take-profit placed on the exchange immediately after every entry |
|
||||
| Drawdown auto-close | Profitable positions that give back too much from their peak are closed |
|
||||
| Trade throttling | Minimum hold times, per-symbol re-entry cooldowns, per-cycle and per-hour entry limits |
|
||||
| Safe mode | Repeated model failures block new entries until the model recovers |
|
||||
| Launch preflight | Model access, wallet funds, strategy, and exchange balances verified before a trader may start |
|
||||
|
||||
---
|
||||
Each decision is stored with the model's full reasoning. There is no position without a paper trail.
|
||||
|
||||
## AI model access
|
||||
<br/>
|
||||
|
||||
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.
|
||||
## Terminal
|
||||
|
||||
| Provider | Access |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [Access pay-as-you-go AI models with official discount](https://claw402.ai) |
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Autopilot** | Guided launch: fund, connect, deposit, start — with server-side preflight throughout |
|
||||
| **Strategy Studio** | Style presets, coin universes, indicators, leverage, entry confidence, custom prompts |
|
||||
| **Competition** | Public leaderboard ranked by realized return, each entry attributed to its model |
|
||||
| **Dashboard** | Live positions, orders, statistics, and the reasoning behind every decision |
|
||||
|
||||
<details>
|
||||
<summary><b>Config Page</b></summary>
|
||||
<summary>Screenshots</summary>
|
||||
|
||||
| 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>
|
||||
<br/>
|
||||
|
||||
| Overview | Market Chart |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
@@ -129,131 +107,51 @@ NOFX routes AI inference through [Claw402](https://claw402.ai) automatically. Us
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="screenshots/dashboard-trading-stats.png" width="400"/> | <img src="screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| Positions | Trader Details |
|
||||
| :----------------------------------------------------------: | :---------------------------------------------------: |
|
||||
| <img src="screenshots/dashboard-positions.png" width="400"/> | <img src="screenshots/details-page.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Strategy Studio</b></summary>
|
||||
|
||||
| Strategy Editor | Indicators Config |
|
||||
| :------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="screenshots/strategy-studio.png" width="400"/> | <img src="screenshots/strategy-indicators.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Competition</b></summary>
|
||||
|
||||
| Competition Mode |
|
||||
| :-------------------------------------------------------: |
|
||||
| <img src="screenshots/competition-page.png" width="400"/> |
|
||||
| Competition | Configuration |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="screenshots/competition-page.png" width="400"/> | <img src="screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Install
|
||||
## Models
|
||||
|
||||
### Linux / macOS
|
||||
Eight providers with your own keys — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — including custom endpoints and model names.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
Or no keys at all: [Claw402](https://claw402.ai) meters model usage per call in USDC over the x402 protocol. A wallet on Base replaces every API key.
|
||||
|
||||
### Railway (Cloud)
|
||||
| Provider | Access |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [Pay-as-you-go AI models with official discount](https://claw402.ai) |
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
## Markets
|
||||
|
||||
### Docker
|
||||
Crypto perpetuals on all nine exchanges. On Hyperliquid, the same runtime also trades tokenized US equities, commodities, indices, FX, and pre-IPO perps — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — alongside crypto.
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Install [Docker Desktop](https://www.docker.com/products/docker-desktop/), then:
|
||||
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
# Prerequisites: Go 1.21+, Node.js 18+, TA-Lib
|
||||
# macOS: brew install ta-lib
|
||||
# Ubuntu: sudo apt-get install libta-lib0-dev
|
||||
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend (new terminal)
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
**Beginner mode**: Guided onboarding walks new users through model selection, exchange connection, strategy setup, and first deployment.
|
||||
|
||||
**Advanced mode**:
|
||||
|
||||
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
|
||||
|
||||
All configuration is available from the web UI at **http://127.0.0.1:3000**.
|
||||
|
||||
---
|
||||
|
||||
## Deploy to server
|
||||
|
||||
**HTTP deployment:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# Access via http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**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
|
||||
4. Set `TRANSPORT_ENCRYPTION=true` in `.env`
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
@@ -261,26 +159,86 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Docs
|
||||
## Install
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
**Windows** — install [Docker Desktop](https://www.docker.com/products/docker-desktop/), then:
|
||||
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
**From source** — Go 1.21+, Node.js 18+:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
**Update** — re-run the install script; it upgrades in place.
|
||||
|
||||
<details>
|
||||
<summary>Server deployment</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**HTTPS via Cloudflare**
|
||||
|
||||
1. Add the domain to [Cloudflare](https://dash.cloudflare.com) (free plan)
|
||||
2. A record → server IP, proxied
|
||||
3. SSL/TLS → Flexible
|
||||
4. `TRANSPORT_ENCRYPTION=true` in `.env`
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## Documentation
|
||||
|
||||
| | |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [Getting Started](docs/getting-started/README.md) | Deployment and exchange API guides |
|
||||
| [Architecture](docs/architecture/README.md) | System design and module index |
|
||||
| [Strategy Module](docs/architecture/STRATEGY_MODULE.md) | Coin selection, AI prompts, execution |
|
||||
| [FAQ](docs/faq/README.md) | Common questions |
|
||||
| [Getting Started](docs/getting-started/README.md) | Deployment guide |
|
||||
| [FAQ](docs/guides/faq.en.md) | Common questions |
|
||||
| [Troubleshooting](docs/guides/TROUBLESHOOTING.md) | Diagnosing common issues |
|
||||
|
||||
---
|
||||
## Community
|
||||
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [Live dashboard](https://vergex.trade/explore)
|
||||
|
||||
## Contributing
|
||||
|
||||
See [Contributing Guide](CONTRIBUTING.md), [Code of Conduct](CODE_OF_CONDUCT.md), and [Security Policy](SECURITY.md).
|
||||
Code, documentation, translations, and bug reports are all welcome — see the [Contributing Guide](CONTRIBUTING.md), [Code of Conduct](CODE_OF_CONDUCT.md), and [Security Policy](SECURITY.md).
|
||||
|
||||
### Contributor Airdrop Program
|
||||
|
||||
NOFX tracks meaningful contributions and intends to reward contributors as the ecosystem grows. Priority issues carry higher reward weight.
|
||||
NOFX tracks meaningful contributions and intends to reward contributors as the ecosystem grows. Priority issues carry higher weight.
|
||||
|
||||
| Contribution | Weight |
|
||||
| :---------------- | :----: |
|
||||
@@ -291,20 +249,9 @@ NOFX tracks meaningful contributions and intends to reward contributors as the e
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
|
||||
| | |
|
||||
| :-------- | :---------------------------------------------------- |
|
||||
| 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**: Automated trading involves substantial risk. Use appropriate position sizing, understand each exchange venue, and do not trade funds you cannot afford to lose.
|
||||
|
||||
---
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## Sponsors
|
||||
|
||||
@@ -322,8 +269,14 @@ NOFX tracks meaningful contributions and intends to reward contributors as the e
|
||||
|
||||
[Become a sponsor](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
<br/>
|
||||
|
||||
If NOFX is useful to you, a star helps other traders find it.
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>Automated trading involves substantial risk. AI-driven strategies are experimental and can lose money. Size positions appropriately, understand each venue, and never trade funds you cannot afford to lose. Full [disclaimer](DISCLAIMER.md).</sub>
|
||||
|
||||
@@ -165,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
|
||||
@@ -386,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
|
||||
@@ -410,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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
const (
|
||||
defaultHyperliquidBuilderAddress = "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d"
|
||||
// 0.05% (万5) — matches BuilderInfo.Fee=50 charged at order placement.
|
||||
// 0.05% (5 bps) — matches BuilderInfo.Fee=50 charged at order placement.
|
||||
// New wallet approvals sign this exact value; existing approvals at the
|
||||
// prior 0.1% cap remain valid because 0.05% is within their approved max.
|
||||
defaultHyperliquidBuilderMaxFee = "0.05%"
|
||||
|
||||
@@ -26,6 +26,7 @@ type beginnerOnboardingResponse struct {
|
||||
DefaultModel string `json:"default_model"`
|
||||
ConfiguredModelID string `json:"configured_model_id"`
|
||||
BalanceUSDC string `json:"balance_usdc"`
|
||||
BalanceStatus string `json:"balance_status,omitempty"`
|
||||
EnvSaved bool `json:"env_saved"`
|
||||
EnvPath string `json:"env_path,omitempty"`
|
||||
ReusedExisting bool `json:"reused_existing"`
|
||||
@@ -36,10 +37,22 @@ type currentBeginnerWalletResponse struct {
|
||||
Found bool `json:"found"`
|
||||
Address string `json:"address,omitempty"`
|
||||
BalanceUSDC string `json:"balance_usdc,omitempty"`
|
||||
BalanceStatus string `json:"balance_status,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Claw402Status string `json:"claw402_status"`
|
||||
}
|
||||
|
||||
// queryBeginnerWalletBalance returns the wallet balance plus a status flag so
|
||||
// the UI can distinguish "RPC unreachable" from a genuinely empty wallet.
|
||||
// Uses the 30s cache — safe for UI polling.
|
||||
func queryBeginnerWalletBalance(address string) (balanceUSDC string, balanceStatus string) {
|
||||
balance, err := wallet.QueryUSDCBalanceCached(address)
|
||||
if err != nil {
|
||||
return "", "unknown"
|
||||
}
|
||||
return fmt.Sprintf("%.2f", balance), "ok"
|
||||
}
|
||||
|
||||
func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
@@ -72,6 +85,7 @@ func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
|
||||
os.Setenv("CLAW402_DEFAULT_MODEL", payment.DefaultClaw402Model)
|
||||
|
||||
envSaved, envPath, envErr := persistBeginnerWalletEnv(privateKey, address)
|
||||
balanceUSDC, balanceStatus := queryBeginnerWalletBalance(address)
|
||||
resp := beginnerOnboardingResponse{
|
||||
Address: address,
|
||||
PrivateKey: privateKey,
|
||||
@@ -80,7 +94,8 @@ func (s *Server) handleBeginnerOnboarding(c *gin.Context) {
|
||||
Provider: "claw402",
|
||||
DefaultModel: payment.DefaultClaw402Model,
|
||||
ConfiguredModelID: configuredModelID,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
BalanceUSDC: balanceUSDC,
|
||||
BalanceStatus: balanceStatus,
|
||||
EnvSaved: envSaved,
|
||||
EnvPath: envPath,
|
||||
ReusedExisting: reusedExisting,
|
||||
@@ -124,10 +139,12 @@ func (s *Server) handleCurrentBeginnerWallet(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
balanceUSDC, balanceStatus := queryBeginnerWalletBalance(address)
|
||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
||||
Found: true,
|
||||
Address: address,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
BalanceUSDC: balanceUSDC,
|
||||
BalanceStatus: balanceStatus,
|
||||
Source: "model",
|
||||
Claw402Status: claw402Status,
|
||||
})
|
||||
@@ -136,10 +153,12 @@ func (s *Server) handleCurrentBeginnerWallet(c *gin.Context) {
|
||||
|
||||
address := strings.TrimSpace(os.Getenv("CLAW402_WALLET_ADDRESS"))
|
||||
if address != "" {
|
||||
balanceUSDC, balanceStatus := queryBeginnerWalletBalance(address)
|
||||
c.JSON(http.StatusOK, currentBeginnerWalletResponse{
|
||||
Found: true,
|
||||
Address: address,
|
||||
BalanceUSDC: wallet.QueryUSDCBalanceStr(address),
|
||||
BalanceUSDC: balanceUSDC,
|
||||
BalanceStatus: balanceStatus,
|
||||
Source: "env",
|
||||
Claw402Status: claw402Status,
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
@@ -206,21 +207,43 @@ func (s *Server) handlePositionHistory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetString("user_id")
|
||||
if fullCfg, cfgErr := s.store.Trader().GetFullConfig(userID, traderID); cfgErr == nil && fullCfg.Exchange != nil {
|
||||
if syncErr := s.syncOrdersFromExchange(
|
||||
trader.GetUnderlyingTrader(),
|
||||
trader.GetID(),
|
||||
fullCfg.Exchange.ID,
|
||||
fullCfg.Exchange.ExchangeType,
|
||||
); syncErr != nil {
|
||||
logger.Infof("⚠️ Position history refresh sync skipped: %v", syncErr)
|
||||
}
|
||||
}
|
||||
|
||||
traderIDs := []string{trader.GetID()}
|
||||
var traderIDPatterns []string
|
||||
if strings.EqualFold(strings.TrimSpace(trader.GetName()), "NOFX Autopilot") && strings.TrimSpace(userID) != "" {
|
||||
// Older one-click launches created new Autopilot trader rows. When a row was
|
||||
// deleted, its closed position records remained under the old generated ID.
|
||||
// The generated Autopilot ID embeds userID + "claw402", so this safely
|
||||
// restores same-user history continuity without joining deleted rows.
|
||||
traderIDPatterns = append(traderIDPatterns, "%_"+userID+"_claw402_%")
|
||||
}
|
||||
|
||||
// Get closed positions
|
||||
positions, err := store.Position().GetClosedPositions(trader.GetID(), limit)
|
||||
positions, err := store.Position().GetClosedPositionsByTraderFilters(traderIDs, traderIDPatterns, limit)
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get position history", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get statistics
|
||||
stats, _ := store.Position().GetFullStats(trader.GetID())
|
||||
stats, _ := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns, trader.GetInitialBalance())
|
||||
|
||||
// Get symbol stats
|
||||
symbolStats, _ := store.Position().GetSymbolStats(trader.GetID(), 10)
|
||||
symbolStats, _ := store.Position().GetSymbolStatsByTraderFilters(traderIDs, traderIDPatterns, 10)
|
||||
|
||||
// Get direction stats
|
||||
directionStats, _ := store.Position().GetDirectionStats(trader.GetID())
|
||||
directionStats, _ := store.Position().GetDirectionStatsByTraderFilters(traderIDs, traderIDPatterns)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"positions": positions,
|
||||
|
||||
58
api/handler_stats_full.go
Normal file
58
api/handler_stats_full.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// handleStatisticsFull returns the full set of computed performance metrics for
|
||||
// a single trader: win rate, profit factor, Sharpe ratio, max drawdown, and the
|
||||
// average win/loss amounts. These are derived from the trader's CLOSED positions
|
||||
// via store.Position().GetFullStatsByTraderFilters — the same computation the
|
||||
// strategy engine feeds to the AI, so the dashboard and the model see identical
|
||||
// numbers.
|
||||
//
|
||||
// The existing GET /statistics endpoint only returns cycle/position counts; this
|
||||
// endpoint exposes the richer trade-quality metrics the terminal dashboard needs.
|
||||
func (s *Server) handleStatisticsFull(c *gin.Context) {
|
||||
_, traderID, err := s.getTraderFromQuery(c)
|
||||
if err != nil {
|
||||
SafeBadRequest(c, "Invalid trader ID")
|
||||
return
|
||||
}
|
||||
|
||||
trader, err := s.traderManager.GetTrader(traderID)
|
||||
if err != nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
store := trader.GetStore()
|
||||
if store == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Aggregate across the trader's historical IDs exactly like the position
|
||||
// history endpoint (handler_order.go). One-click "NOFX Autopilot" relaunches
|
||||
// create fresh trader rows, but the closed positions stay under the old
|
||||
// generated IDs (which embed userID + "claw402"). Without this, a freshly
|
||||
// relaunched Autopilot would report only the current incarnation's trades
|
||||
// instead of its real lifetime history.
|
||||
userID := c.GetString("user_id")
|
||||
traderIDs := []string{trader.GetID()}
|
||||
var traderIDPatterns []string
|
||||
if strings.EqualFold(strings.TrimSpace(trader.GetName()), "NOFX Autopilot") && strings.TrimSpace(userID) != "" {
|
||||
traderIDPatterns = append(traderIDPatterns, "%_"+userID+"_claw402_%")
|
||||
}
|
||||
|
||||
stats, err := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns, trader.GetInitialBalance())
|
||||
if err != nil {
|
||||
SafeInternalError(c, "Get full statistics", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
@@ -61,21 +61,21 @@ type UpdateTraderRequest struct {
|
||||
|
||||
func formatTraderCreationError(reason, nextStep string) string {
|
||||
if nextStep == "" {
|
||||
return fmt.Sprintf("这次未能创建机器人:%s。", reason)
|
||||
return fmt.Sprintf("Failed to create the bot this time: %s.", reason)
|
||||
}
|
||||
return fmt.Sprintf("这次未能创建机器人:%s。%s。", reason, nextStep)
|
||||
return fmt.Sprintf("Failed to create the bot this time: %s. %s.", reason, nextStep)
|
||||
}
|
||||
|
||||
func traderCreationRequestError(reason string) string {
|
||||
return formatTraderCreationError(reason, "请检查你刚刚填写的内容后,再重新提交")
|
||||
return formatTraderCreationError(reason, "Please check the information you just entered and submit again")
|
||||
}
|
||||
|
||||
func validateTraderLeverageRange(btcEthLeverage, altcoinLeverage int) (string, string) {
|
||||
if btcEthLeverage < 0 || btcEthLeverage > maxManualBTCETHLeverage {
|
||||
return traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_btc_eth_leverage"
|
||||
return traderCreationRequestError("BTC/ETH leverage must be between 1x and 20x"), "trader.create.invalid_btc_eth_leverage"
|
||||
}
|
||||
if altcoinLeverage < 0 || altcoinLeverage > maxManualAltLeverage {
|
||||
return traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage"
|
||||
return traderCreationRequestError("Altcoin leverage must be between 1x and 20x"), "trader.create.invalid_altcoin_leverage"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
@@ -90,15 +90,15 @@ func isSupportedTraderSymbol(symbol string) bool {
|
||||
|
||||
func exchangeDisplayName(exchange *store.Exchange) string {
|
||||
if exchange == nil {
|
||||
return "所选交易所账户"
|
||||
return "the selected exchange account"
|
||||
}
|
||||
if exchange.AccountName != "" {
|
||||
return fmt.Sprintf("%s(%s)", exchange.Name, exchange.AccountName)
|
||||
return fmt.Sprintf("%s (%s)", exchange.Name, exchange.AccountName)
|
||||
}
|
||||
if exchange.Name != "" {
|
||||
return exchange.Name
|
||||
}
|
||||
return "所选交易所账户"
|
||||
return "the selected exchange account"
|
||||
}
|
||||
|
||||
func missingExchangeFields(exchange *store.Exchange) []string {
|
||||
@@ -127,10 +127,10 @@ func missingExchangeFields(exchange *store.Exchange) []string {
|
||||
}
|
||||
case "hyperliquid":
|
||||
if exchange.APIKey == "" {
|
||||
missing = append(missing, "私钥")
|
||||
missing = append(missing, "Private Key")
|
||||
}
|
||||
if strings.TrimSpace(exchange.HyperliquidWalletAddr) == "" {
|
||||
missing = append(missing, "钱包地址")
|
||||
missing = append(missing, "Wallet Address")
|
||||
}
|
||||
case "aster":
|
||||
if strings.TrimSpace(exchange.AsterUser) == "" {
|
||||
@@ -144,7 +144,7 @@ func missingExchangeFields(exchange *store.Exchange) []string {
|
||||
}
|
||||
case "lighter":
|
||||
if strings.TrimSpace(exchange.LighterWalletAddr) == "" {
|
||||
missing = append(missing, "钱包地址")
|
||||
missing = append(missing, "Wallet Address")
|
||||
}
|
||||
if exchange.LighterAPIKeyPrivateKey == "" {
|
||||
missing = append(missing, "API Key Private Key")
|
||||
@@ -168,21 +168,21 @@ func mapStringPairs(kv ...string) map[string]string {
|
||||
|
||||
func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string, map[string]string) {
|
||||
if exchange == nil {
|
||||
return formatTraderCreationError("还没有找到你选择的交易所账户", "请前往「设置 > 交易所配置」先添加一个可用账户,再回来创建机器人"),
|
||||
return formatTraderCreationError("The exchange account you selected was not found", "Please go to \"Settings > Exchange Config\" and add an available account first, then come back to create the bot"),
|
||||
"trader.create.exchange_not_found", nil
|
||||
}
|
||||
if !exchange.Enabled {
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」目前处于未启用状态", exchangeDisplayName(exchange)),
|
||||
"请前往「设置 > 交易所配置」启用该账户后,再重新创建机器人",
|
||||
fmt.Sprintf("Exchange account \"%s\" is currently disabled", exchangeDisplayName(exchange)),
|
||||
"Please go to \"Settings > Exchange Config\" to enable this account, then create the bot again",
|
||||
), "trader.create.exchange_disabled", mapStringPairs("exchange_name", exchangeDisplayName(exchange))
|
||||
}
|
||||
|
||||
missing := missingExchangeFields(exchange)
|
||||
if len(missing) > 0 {
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」的配置还不完整,缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")),
|
||||
"请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人",
|
||||
fmt.Sprintf("The configuration for exchange account \"%s\" is incomplete, missing %s", exchangeDisplayName(exchange), strings.Join(missing, ", ")),
|
||||
"Please go to \"Settings > Exchange Config\" to complete the required information for this account, then create the bot again",
|
||||
), "trader.create.exchange_missing_fields", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"missing_fields", strings.Join(missing, ", "),
|
||||
@@ -194,8 +194,8 @@ func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string
|
||||
return "", "", nil
|
||||
default:
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType),
|
||||
"请改用当前版本支持的交易所账户后,再重新创建机器人",
|
||||
fmt.Sprintf("Exchange account \"%s\" uses type %s, which is not supported in the current version", exchangeDisplayName(exchange), exchange.ExchangeType),
|
||||
"Please switch to an exchange account supported by the current version, then create the bot again",
|
||||
), "trader.create.exchange_unsupported", mapStringPairs(
|
||||
"exchange_name", exchangeDisplayName(exchange),
|
||||
"exchange_type", exchange.ExchangeType,
|
||||
@@ -214,28 +214,28 @@ func classifyTraderSetupReason(reason string) (string, string) {
|
||||
switch {
|
||||
case strings.Contains(lower, "failed to parse strategy config"),
|
||||
strings.Contains(lower, "failed to parse strategy configuration"):
|
||||
return "trader.reason.strategy_config_invalid", "当前策略配置内容已损坏,系统暂时无法解析"
|
||||
return "trader.reason.strategy_config_invalid", "The current strategy configuration is corrupted and the system cannot parse it for now"
|
||||
case strings.Contains(lower, "has no strategy configured"):
|
||||
return "trader.reason.strategy_missing", "当前机器人缺少有效的交易策略配置"
|
||||
return "trader.reason.strategy_missing", "The current bot is missing a valid trading strategy configuration"
|
||||
case strings.Contains(lower, "failed to parse private key"),
|
||||
(strings.Contains(lower, "invalid hex character") && strings.Contains(lower, "private key")):
|
||||
return "trader.reason.private_key_invalid", "私钥格式不正确,系统无法识别"
|
||||
return "trader.reason.private_key_invalid", "The private key format is incorrect and the system cannot recognize it"
|
||||
case strings.Contains(lower, "failed to initialize hyperliquid trader"):
|
||||
return "trader.reason.hyperliquid_init_failed", "Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确"
|
||||
return "trader.reason.hyperliquid_init_failed", "Hyperliquid account initialization failed; please confirm the private key, main wallet address, and Agent Wallet configuration are correct"
|
||||
case strings.Contains(lower, "failed to initialize aster trader"):
|
||||
return "trader.reason.aster_init_failed", "Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确"
|
||||
return "trader.reason.aster_init_failed", "Aster account initialization failed; please confirm the Aster User, Signer, and private key are correct"
|
||||
case strings.Contains(lower, "failed to get meta information"):
|
||||
return "trader.reason.exchange_meta_unavailable", "系统暂时无法从交易所读取账户元信息"
|
||||
return "trader.reason.exchange_meta_unavailable", "The system cannot read account meta information from the exchange for now"
|
||||
case strings.Contains(lower, "security check failed") && strings.Contains(lower, "agent wallet balance too high"):
|
||||
return "trader.reason.hyperliquid_agent_balance_too_high", "Hyperliquid Agent Wallet 余额过高,不符合当前安全要求"
|
||||
return "trader.reason.hyperliquid_agent_balance_too_high", "The Hyperliquid Agent Wallet balance is too high and does not meet the current security requirements"
|
||||
case strings.Contains(lower, "failed to initialize account"):
|
||||
return "trader.reason.exchange_account_init_failed", "交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配"
|
||||
return "trader.reason.exchange_account_init_failed", "Exchange account initialization failed; please confirm the wallet address and API Key match"
|
||||
case strings.Contains(lower, "unsupported trading platform"):
|
||||
return "trader.reason.exchange_unsupported", "当前交易所类型暂不支持机器人初始化"
|
||||
return "trader.reason.exchange_unsupported", "The current exchange type does not support bot initialization"
|
||||
case strings.Contains(lower, "initial balance not set and unable to fetch balance from exchange"):
|
||||
return "trader.reason.exchange_balance_unavailable", "系统暂时无法从交易所读取账户余额"
|
||||
return "trader.reason.exchange_balance_unavailable", "The system cannot read the account balance from the exchange for now"
|
||||
case strings.Contains(lower, "timeout"), strings.Contains(lower, "no such host"), strings.Contains(lower, "connection refused"):
|
||||
return "trader.reason.exchange_service_unreachable", "系统暂时无法连接交易所服务"
|
||||
return "trader.reason.exchange_service_unreachable", "The system cannot connect to the exchange service for now"
|
||||
default:
|
||||
return "trader.reason.unknown", trimmed
|
||||
}
|
||||
@@ -270,54 +270,54 @@ func traderSetupReasonParams(err error, fallback string, kv ...string) map[strin
|
||||
|
||||
func describeTraderLoadError(traderName string, err error) string {
|
||||
if err == nil {
|
||||
return formatTraderCreationError("机器人配置虽然保存了,但运行实例没有成功初始化", "请检查模型、策略和交易所配置是否完整,然后再试一次")
|
||||
return formatTraderCreationError("The bot configuration was saved, but the runtime instance failed to initialize", "Please check that the model, strategy, and exchange configuration are complete, then try again")
|
||||
}
|
||||
|
||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
||||
if reason == "" {
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动", traderName),
|
||||
"请检查模型、策略和交易所配置是否完整,然后再试一次",
|
||||
fmt.Sprintf("Bot \"%s\" failed to start when initializing its runtime instance", traderName),
|
||||
"Please check that the model, strategy, and exchange configuration are complete, then try again",
|
||||
)
|
||||
}
|
||||
|
||||
return formatTraderCreationError(
|
||||
fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动,原因是:%s", traderName, reason),
|
||||
"请检查模型、策略和交易所配置是否完整,然后再试一次",
|
||||
fmt.Sprintf("Bot \"%s\" failed to start when initializing its runtime instance, because: %s", traderName, reason),
|
||||
"Please check that the model, strategy, and exchange configuration are complete, then try again",
|
||||
)
|
||||
}
|
||||
|
||||
func describeTraderCreationWarning(traderName string, err error) string {
|
||||
if err == nil {
|
||||
return fmt.Sprintf("机器人「%s」已经保存,但当前还没有通过启动前校验。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName)
|
||||
return fmt.Sprintf("Bot \"%s\" has been saved, but it has not yet passed the pre-start validation. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName)
|
||||
}
|
||||
|
||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
||||
if reason == "" {
|
||||
return fmt.Sprintf("机器人「%s」已经保存,但当前暂时还不能启动。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName)
|
||||
return fmt.Sprintf("Bot \"%s\" has been saved, but it cannot start for now. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("机器人「%s」已经保存,但当前暂时还不能启动,原因是:%s。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName, reason)
|
||||
return fmt.Sprintf("Bot \"%s\" has been saved, but it cannot start for now, because: %s. Please check the model, strategy, and exchange configuration first, then click start after fixing them.", traderName, reason)
|
||||
}
|
||||
|
||||
func describeTraderStartError(traderName string, err error) string {
|
||||
if err == nil {
|
||||
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后,再重新点击启动。", traderName)
|
||||
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now. Please check the model, strategy, and exchange configuration, then click start again.", traderName)
|
||||
}
|
||||
|
||||
reason := humanizeTraderSetupReason(SanitizeError(err, ""))
|
||||
if reason == "" {
|
||||
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后,再重新点击启动。", traderName)
|
||||
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now. Please check the model, strategy, and exchange configuration, then click start again.", traderName)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动,原因是:%s。请检查模型、策略和交易所配置后,再重新点击启动。", traderName, reason)
|
||||
return fmt.Sprintf("Failed to start the bot this time: bot \"%s\" cannot start for now, because: %s. Please check the model, strategy, and exchange configuration, then click start again.", traderName, reason)
|
||||
}
|
||||
|
||||
func formatTraderStartError(reason, nextStep string) string {
|
||||
if nextStep == "" {
|
||||
return fmt.Sprintf("这次未能启动机器人:%s。", reason)
|
||||
return fmt.Sprintf("Failed to start the bot this time: %s.", reason)
|
||||
}
|
||||
return fmt.Sprintf("这次未能启动机器人:%s。%s。", reason, nextStep)
|
||||
return fmt.Sprintf("Failed to start the bot this time: %s. %s.", reason, nextStep)
|
||||
}
|
||||
|
||||
// handleCreateTrader Create new AI trader
|
||||
@@ -325,7 +325,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
var req CreateTraderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("提交的信息不完整,或者格式不正确"), "trader.create.invalid_request", nil)
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError("The submitted information is incomplete or has an invalid format"), "trader.create.invalid_request", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -344,7 +344,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
symbol = strings.TrimSpace(symbol)
|
||||
if !isSupportedTraderSymbol(symbol) {
|
||||
SafeBadRequestWithDetails(c, traderCreationRequestError(
|
||||
fmt.Sprintf("交易对 %s 的格式不正确,目前只支持 USDT 合约或 Hyperliquid XYZ USDC 标的(SYMBOL-USDC)", symbol),
|
||||
fmt.Sprintf("The trading pair %s has an invalid format; only USDT perpetuals or Hyperliquid XYZ USDC instruments (SYMBOL-USDC) are currently supported", symbol),
|
||||
), "trader.create.invalid_symbol", mapStringPairs("symbol", symbol))
|
||||
return
|
||||
}
|
||||
@@ -354,32 +354,32 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
model, err := s.store.AIModel().Get(userID, req.AIModelID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("还没有找到你选择的 AI 模型", "请前往「设置 > 模型配置」先添加并启用一个可用模型,再回来创建机器人"), "trader.create.model_not_found", nil)
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("The AI model you selected was not found", "Please go to \"Settings > Model Config\" to add and enable an available model first, then come back to create the bot"), "trader.create.model_not_found", nil)
|
||||
return
|
||||
}
|
||||
SafeError(c, http.StatusInternalServerError,
|
||||
formatTraderCreationError("暂时无法读取你的 AI 模型配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
|
||||
formatTraderCreationError("Unable to read your AI model configuration for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
|
||||
err,
|
||||
)
|
||||
return
|
||||
}
|
||||
if !model.Enabled {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
||||
fmt.Sprintf("AI 模型「%s」目前还没有启用", model.Name),
|
||||
"请前往「设置 > 模型配置」启用它后,再重新创建机器人",
|
||||
fmt.Sprintf("AI model \"%s\" is not enabled yet", model.Name),
|
||||
"Please go to \"Settings > Model Config\" to enable it, then create the bot again",
|
||||
), "trader.create.model_disabled", mapStringPairs("model_name", model.Name))
|
||||
return
|
||||
}
|
||||
if model.APIKey == "" {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
||||
fmt.Sprintf("AI 模型「%s」缺少 API Key 或支付凭证", model.Name),
|
||||
"请前往「设置 > 模型配置」补全模型凭证后,再重新创建机器人",
|
||||
fmt.Sprintf("AI model \"%s\" is missing an API Key or payment credentials", model.Name),
|
||||
"Please go to \"Settings > Model Config\" to complete the model credentials, then create the bot again",
|
||||
), "trader.create.model_missing_credentials", mapStringPairs("model_name", model.Name))
|
||||
return
|
||||
}
|
||||
|
||||
if req.StrategyID == "" {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("你还没有选择交易策略", "请先选择一个策略,再继续创建机器人"), "trader.create.strategy_required", nil)
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("You have not selected a trading strategy yet", "Please select a strategy first, then continue creating the bot"), "trader.create.strategy_required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -387,11 +387,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
_, err = s.store.Strategy().Get(userID, req.StrategyID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("你选择的策略不存在,或者已经被删除了", "请重新选择一个可用策略后,再继续创建机器人"), "trader.create.strategy_not_found", nil)
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError("The strategy you selected does not exist or has been deleted", "Please select another available strategy, then continue creating the bot"), "trader.create.strategy_not_found", nil)
|
||||
return
|
||||
}
|
||||
SafeError(c, http.StatusInternalServerError,
|
||||
formatTraderCreationError("暂时无法读取你选择的策略配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
|
||||
formatTraderCreationError("Unable to read the strategy configuration you selected for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
|
||||
err,
|
||||
)
|
||||
return
|
||||
@@ -445,7 +445,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
exchanges, err := s.store.Exchange().List(userID)
|
||||
if err != nil {
|
||||
SafeError(c, http.StatusInternalServerError,
|
||||
formatTraderCreationError("暂时无法读取你的交易所配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"),
|
||||
formatTraderCreationError("Unable to read your exchange configuration for now", "Please retry later; if the problem persists, check whether the local service is running normally"),
|
||||
err,
|
||||
)
|
||||
return
|
||||
@@ -469,9 +469,9 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
|
||||
if createErr != nil {
|
||||
SafeBadRequestWithDetails(c, formatTraderCreationError(
|
||||
fmt.Sprintf("交易所账户「%s」没有通过初始化校验,原因是:%s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "配置校验未通过"))),
|
||||
"请前往「设置 > 交易所配置」检查这个账户的密钥、地址和账户信息是否填写正确",
|
||||
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "配置校验未通过",
|
||||
fmt.Sprintf("Exchange account \"%s\" did not pass initialization validation, because: %s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "Configuration validation failed"))),
|
||||
"Please go to \"Settings > Exchange Config\" to check whether this account's keys, address, and account information are entered correctly",
|
||||
), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "Configuration validation failed",
|
||||
"exchange_name", exchangeDisplayName(exchangeCfg),
|
||||
))
|
||||
return
|
||||
@@ -521,9 +521,9 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
err = s.store.Trader().Create(traderRecord)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to create trader: %v", err)
|
||||
publicMsg := SanitizeError(err, formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次"))
|
||||
publicMsg := SanitizeError(err, formatTraderCreationError("The bot configuration was not saved successfully", "Please check the name, model, strategy, and exchange configuration, then try again"))
|
||||
statusCode := http.StatusBadRequest
|
||||
if publicMsg == formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次") {
|
||||
if publicMsg == formatTraderCreationError("The bot configuration was not saved successfully", "Please check the name, model, strategy, and exchange configuration, then try again") {
|
||||
statusCode = http.StatusInternalServerError
|
||||
}
|
||||
SafeError(c, statusCode, publicMsg, err)
|
||||
@@ -731,7 +731,10 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
|
||||
"trader_id": traderID,
|
||||
"trader_name": req.Name,
|
||||
"ai_model": req.AIModelID,
|
||||
"message": "Trader updated successfully",
|
||||
// A running trader is restarted with the new config (async above), so
|
||||
// report it as running — callers must not fire a redundant start.
|
||||
"is_running": wasRunning,
|
||||
"message": "Trader updated successfully",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -781,8 +784,8 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
|
||||
if fullCfg != nil && fullCfg.Exchange != nil && fullCfg.Exchange.ExchangeType == "hyperliquid" && !fullCfg.Exchange.HyperliquidBuilderApproved {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError(
|
||||
fmt.Sprintf("机器人「%s」的 Hyperliquid 交易授权尚未完成", traderName),
|
||||
"请重新连接 Hyperliquid 钱包并完成交易授权后,再启动机器人",
|
||||
fmt.Sprintf("The Hyperliquid trading authorization for bot \"%s\" is not yet complete", traderName),
|
||||
"Please reconnect the Hyperliquid wallet and complete the trading authorization, then start the bot",
|
||||
), "trader.start.hyperliquid_builder_not_approved", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
|
||||
return
|
||||
}
|
||||
@@ -792,7 +795,10 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
if existingTrader != nil {
|
||||
status := existingTrader.GetStatus()
|
||||
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader is already running"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Trader is already running",
|
||||
"error_key": "trader.start.already_running",
|
||||
})
|
||||
return
|
||||
}
|
||||
// Trader exists but is stopped - remove from memory to reload fresh config
|
||||
@@ -818,25 +824,25 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
}
|
||||
// Check AI model
|
||||
if fullCfg.AIModel == nil {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的 AI 模型不存在", "请前往「设置 > 模型配置」检查后,再重新点击启动"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError("The AI model associated with this bot does not exist", "Please go to \"Settings > Model Config\" to check, then click start again"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName))
|
||||
return
|
||||
}
|
||||
if !fullCfg.AIModel.Enabled {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError(
|
||||
fmt.Sprintf("机器人「%s」关联的 AI 模型「%s」目前还没有启用", traderName, fullCfg.AIModel.Name),
|
||||
"请前往「设置 > 模型配置」启用它后,再重新点击启动",
|
||||
fmt.Sprintf("The AI model \"%s\" associated with bot \"%s\" is not enabled yet", fullCfg.AIModel.Name, traderName),
|
||||
"Please go to \"Settings > Model Config\" to enable it, then click start again",
|
||||
), "trader.start.model_disabled", mapStringPairs("trader_name", traderName, "model_name", fullCfg.AIModel.Name))
|
||||
return
|
||||
}
|
||||
// Check exchange
|
||||
if fullCfg.Exchange == nil {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的交易所账户不存在", "请前往「设置 > 交易所配置」检查后,再重新点击启动"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError("The exchange account associated with this bot does not exist", "Please go to \"Settings > Exchange Config\" to check, then click start again"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName))
|
||||
return
|
||||
}
|
||||
if !fullCfg.Exchange.Enabled {
|
||||
SafeBadRequestWithDetails(c, formatTraderStartError(
|
||||
fmt.Sprintf("机器人「%s」关联的交易所账户「%s」目前还没有启用", traderName, exchangeDisplayName(fullCfg.Exchange)),
|
||||
"请前往「设置 > 交易所配置」启用它后,再重新点击启动",
|
||||
fmt.Sprintf("The exchange account \"%s\" associated with bot \"%s\" is not enabled yet", exchangeDisplayName(fullCfg.Exchange), traderName),
|
||||
"Please go to \"Settings > Exchange Config\" to enable it, then click start again",
|
||||
), "trader.start.exchange_disabled", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange)))
|
||||
return
|
||||
}
|
||||
@@ -850,6 +856,24 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Server-side launch gate: the trader cannot function without a funded AI
|
||||
// wallet and a ready exchange account, so verify both before the run loop
|
||||
// starts. `?force=true` skips the gate for deliberate manual overrides.
|
||||
if c.Query("force") != "true" {
|
||||
// strategyRequired=false: a trader that loaded into memory necessarily
|
||||
// has a valid strategy (the manager refuses to load without one), so the
|
||||
// preflight strategy check would be redundant here.
|
||||
preflight := s.runLaunchPreflight(userID, fullCfg.AIModel, fullCfg.Exchange, fullCfg.Strategy, false)
|
||||
if !preflight.Ready {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": formatTraderStartError(preflight.Summary(), "Complete the failing checks, then start the bot again"),
|
||||
"error_key": "trader.start.preflight_failed",
|
||||
"preflight": preflight,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Start trader
|
||||
go func() {
|
||||
logger.Infof("▶️ Starting trader %s (%s)", traderID, trader.GetName())
|
||||
|
||||
@@ -267,8 +267,12 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
||||
|
||||
logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result)
|
||||
|
||||
// Record order to database (for chart markers and history)
|
||||
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
|
||||
|
||||
@@ -229,7 +229,7 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
}
|
||||
locales := map[string]strategyLocale{
|
||||
"zh": {
|
||||
defaultStrategy: strategyI18n{"NOFX Claw402 自动策略", "唯一内置策略:每轮读取 Claw402.ai 榜单,逐个拉取 Signal Lab 与成本/清算热力图,再结合原始 K 线决策。"},
|
||||
defaultStrategy: strategyI18n{"NOFX Claw402 Auto Strategy", "The only built-in strategy: read the Claw402.ai board each cycle, fetch Signal Lab and cost/liquidation heatmap per candidate, then decide with raw candles."},
|
||||
},
|
||||
"en": {
|
||||
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."},
|
||||
@@ -263,10 +263,13 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
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.BTCETHMaxLeverage = 5
|
||||
c.RiskControl.AltcoinMaxLeverage = 5
|
||||
// Few, concentrated positions held for big moves. 5x leverage so a
|
||||
// wide (-5%) stop is survivable rather than an instant liquidation;
|
||||
// 2 positions × 2.5x = 5x total notional (full margin, ~20% cushion).
|
||||
c.RiskControl.BTCETHMaxPositionValueRatio = 2.5
|
||||
c.RiskControl.AltcoinMaxPositionValueRatio = 2.5
|
||||
c.RiskControl.MaxMarginUsage = 1.0
|
||||
c.RiskControl.MinConfidence = 78
|
||||
c.RiskControl.MinRiskRewardRatio = 3.0
|
||||
@@ -318,8 +321,8 @@ func (s *Server) createDefaultStrategies(userID string, lang string) error {
|
||||
}
|
||||
|
||||
legacyDefaultNames := []string{
|
||||
"均衡策略", "稳健策略", "积极策略",
|
||||
"美股趋势策略", "美股稳健策略", "美股突破策略",
|
||||
"Balanced Strategy", "Steady Strategy", "Aggressive Strategy",
|
||||
"US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy",
|
||||
"Balanced Strategy", "Conservative Strategy", "Aggressive Strategy",
|
||||
"US Stock Trend Strategy", "US Stock Steady Strategy", "US Stock Breakout Strategy",
|
||||
"Strategi Seimbang", "Strategi Konservatif", "Strategi Agresif",
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestCreateDefaultStrategiesUsesOneReadyToRunClaw402Preset(t *testing.T) {
|
||||
if strategy.IsActive {
|
||||
activeCount++
|
||||
}
|
||||
if strategy.Name == "均衡策略" || strategy.Name == "稳健策略" || strategy.Name == "积极策略" {
|
||||
if strategy.Name == "Balanced Strategy" || strategy.Name == "Steady Strategy" || strategy.Name == "Aggressive Strategy" {
|
||||
t.Fatalf("legacy crypto-style default strategy still present: %s", strategy.Name)
|
||||
}
|
||||
}
|
||||
@@ -43,9 +43,9 @@ func TestCreateDefaultStrategiesUsesOneReadyToRunClaw402Preset(t *testing.T) {
|
||||
t.Fatalf("expected exactly one active strategy, got %d", activeCount)
|
||||
}
|
||||
|
||||
defaultStrategy := byName["NOFX Claw402 自动策略"]
|
||||
defaultStrategy := byName["NOFX Claw402 Auto Strategy"]
|
||||
if defaultStrategy == nil || !defaultStrategy.IsActive {
|
||||
t.Fatalf("NOFX Claw402 自动策略 should exist and be active")
|
||||
t.Fatalf("NOFX Claw402 Auto Strategy should exist and be active")
|
||||
}
|
||||
trendCfg, err := defaultStrategy.ParseConfig()
|
||||
if err != nil {
|
||||
@@ -54,16 +54,16 @@ func TestCreateDefaultStrategiesUsesOneReadyToRunClaw402Preset(t *testing.T) {
|
||||
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.CoinSource.UseAI500 || trendCfg.RiskControl.MaxPositions != 2 {
|
||||
t.Fatalf("default strategy should be Claw402/Vergex native with a 2-position concentrated book, got coin=%+v risk=%+v", trendCfg.CoinSource, trendCfg.RiskControl)
|
||||
}
|
||||
if trendCfg.RiskControl.BTCETHMaxLeverage != 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.BTCETHMaxLeverage != 5 || trendCfg.RiskControl.AltcoinMaxLeverage != 5 {
|
||||
t.Fatalf("default strategy should use 5x leverage for all Claw402 opens, got risk=%+v", trendCfg.RiskControl)
|
||||
}
|
||||
if trendCfg.RiskControl.BTCETHMaxPositionValueRatio != 10 ||
|
||||
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 10 ||
|
||||
if trendCfg.RiskControl.BTCETHMaxPositionValueRatio != 2.5 ||
|
||||
trendCfg.RiskControl.AltcoinMaxPositionValueRatio != 2.5 ||
|
||||
trendCfg.RiskControl.MaxMarginUsage != 1.0 {
|
||||
t.Fatalf("default strategy should use full-size 10x notional for Claw402 opens, got risk=%+v", trendCfg.RiskControl)
|
||||
t.Fatalf("default strategy should size Claw402 opens at 2.5x equity notional (2 positions = 5x total at 5x), got risk=%+v", trendCfg.RiskControl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestCreateDefaultStrategiesMigratesLegacyPresetsWithoutOverridingActiveCust
|
||||
legacy := &store.Strategy{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: "均衡策略",
|
||||
Name: "Balanced Strategy",
|
||||
Description: "legacy",
|
||||
IsActive: false,
|
||||
}
|
||||
@@ -124,11 +124,11 @@ func TestCreateDefaultStrategiesMigratesLegacyPresetsWithoutOverridingActiveCust
|
||||
activeNames = append(activeNames, strategy.Name)
|
||||
}
|
||||
}
|
||||
if byName["均衡策略"] != 0 {
|
||||
if byName["Balanced Strategy"] != 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 byName["NOFX Claw402 Auto Strategy"] != 1 {
|
||||
t.Fatalf("expected exactly one NOFX Claw402 Auto Strategy, got names=%+v", byName)
|
||||
}
|
||||
if len(activeNames) != 1 || activeNames[0] != "aa" {
|
||||
t.Fatalf("existing active custom strategy should stay the only active one, got %+v", activeNames)
|
||||
|
||||
@@ -73,6 +73,28 @@ func (s *Server) handleVergexCostLiquidationHeatmap(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
||||
}
|
||||
|
||||
// handleVergexFlowMarkets proxies the Vergex net-flow market ranking (paid x402
|
||||
// endpoint) using the caller's claw402 wallet. The upstream JSON is passed
|
||||
// through verbatim: { data: { window, by, inflow: [{ symbol, netFlow,
|
||||
// buyNotional, sellNotional, trades, latestPrice }, ...] } }.
|
||||
func (s *Server) handleVergexFlowMarkets(c *gin.Context) {
|
||||
client, ok := s.newVergexClientForRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
chain := withDefault(strings.TrimSpace(c.Query("chain")), "mainnet")
|
||||
window := withDefault(strings.TrimSpace(c.Query("window")), "1h")
|
||||
limit := parsePositiveInt(c.Query("limit"), 25)
|
||||
|
||||
body, err := client.GetFlowMarkets(context.Background(), chain, window, limit)
|
||||
if err != nil {
|
||||
logger.Warnf("Vergex flow-markets failed: %v", err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
||||
}
|
||||
|
||||
func (s *Server) newVergexClientForRequest(c *gin.Context) (*vergex.Client, bool) {
|
||||
userID := c.GetString("user_id")
|
||||
if userID == "" {
|
||||
|
||||
375
api/launch_preflight.go
Normal file
375
api/launch_preflight.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/store"
|
||||
"nofx/wallet"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Launch readiness minimums. These are the single source of truth — the
|
||||
// frontend reads them from the preflight response instead of hardcoding.
|
||||
const (
|
||||
// MinAIFeeUSDC is the minimum Base USDC a claw402 fee wallet needs so the
|
||||
// trader can pay for its first AI/data calls.
|
||||
MinAIFeeUSDC = 1.0
|
||||
// MinTradingUSDC is the minimum available balance the exchange account
|
||||
// needs before the trader can place its first order.
|
||||
MinTradingUSDC = 12.0
|
||||
)
|
||||
|
||||
const (
|
||||
launchCheckStatusOK = "ok"
|
||||
launchCheckStatusFailed = "failed"
|
||||
launchCheckStatusWarning = "warning"
|
||||
launchCheckStatusSkipped = "skipped"
|
||||
)
|
||||
|
||||
// Check IDs — stable identifiers the frontend maps to guided-setup steps.
|
||||
const (
|
||||
launchCheckAIModel = "ai_model"
|
||||
launchCheckAIWallet = "ai_wallet"
|
||||
launchCheckAIWalletFunds = "ai_wallet_funds"
|
||||
launchCheckStrategy = "strategy"
|
||||
launchCheckExchangeConfig = "exchange_config"
|
||||
launchCheckExchangeAccount = "exchange_account"
|
||||
launchCheckExchangeFunds = "exchange_funds"
|
||||
)
|
||||
|
||||
// queryAIWalletBalance is swappable in tests to avoid live Base RPC calls.
|
||||
var queryAIWalletBalance = wallet.QueryUSDCBalanceCached
|
||||
|
||||
type LaunchCheck struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
// Numeric context so the UI can render progress like "8.40 / 12 USDC".
|
||||
Required float64 `json:"required,omitempty"`
|
||||
Actual *float64 `json:"actual,omitempty"`
|
||||
Asset string `json:"asset,omitempty"`
|
||||
// Address is the funding address for balance checks, so the UI can offer
|
||||
// a deposit shortcut next to the failing item.
|
||||
Address string `json:"address,omitempty"`
|
||||
}
|
||||
|
||||
type LaunchPreflightResult struct {
|
||||
Ready bool `json:"ready"`
|
||||
Checks []LaunchCheck `json:"checks"`
|
||||
MinAIFeeUSDC float64 `json:"min_ai_fee_usdc"`
|
||||
MinTradingUSDC float64 `json:"min_trading_usdc"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
|
||||
func (r LaunchPreflightResult) failedChecks() []LaunchCheck {
|
||||
var failed []LaunchCheck
|
||||
for _, check := range r.Checks {
|
||||
if check.Status == launchCheckStatusFailed {
|
||||
failed = append(failed, check)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// Summary joins the failing messages into one human-readable sentence.
|
||||
func (r LaunchPreflightResult) Summary() string {
|
||||
failed := r.failedChecks()
|
||||
if len(failed) == 0 {
|
||||
return ""
|
||||
}
|
||||
messages := make([]string, 0, len(failed))
|
||||
for _, check := range failed {
|
||||
if check.Message != "" {
|
||||
messages = append(messages, check.Message)
|
||||
}
|
||||
}
|
||||
return strings.Join(messages, " ")
|
||||
}
|
||||
|
||||
type launchPreflightRequest struct {
|
||||
AIModelID string `json:"ai_model_id"`
|
||||
ExchangeID string `json:"exchange_id"`
|
||||
StrategyID string `json:"strategy_id"`
|
||||
}
|
||||
|
||||
// handleLaunchPreflight runs launch readiness checks for a model/exchange
|
||||
// pair before any trader is created or mutated. POST /api/launch/preflight
|
||||
func (s *Server) handleLaunchPreflight(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
|
||||
var req launchPreflightRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
SafeBadRequest(c, "Invalid preflight request")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.AIModelID) == "" || strings.TrimSpace(req.ExchangeID) == "" {
|
||||
SafeBadRequest(c, "ai_model_id and exchange_id are required")
|
||||
return
|
||||
}
|
||||
|
||||
model, err := s.store.AIModel().Get(userID, req.AIModelID)
|
||||
if err != nil {
|
||||
model = nil
|
||||
}
|
||||
exchange, err := s.store.Exchange().GetByID(userID, req.ExchangeID)
|
||||
if err != nil {
|
||||
exchange = nil
|
||||
}
|
||||
|
||||
var strategy *store.Strategy
|
||||
strategyRequired := strings.TrimSpace(req.StrategyID) != ""
|
||||
if strategyRequired {
|
||||
strategy, err = s.store.Strategy().Get(userID, req.StrategyID)
|
||||
if err != nil {
|
||||
strategy = nil
|
||||
}
|
||||
}
|
||||
|
||||
result := s.runLaunchPreflight(userID, model, exchange, strategy, strategyRequired)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// handleTraderPreflight runs the same checks against an existing trader's
|
||||
// full configuration. GET /api/traders/:id/preflight
|
||||
func (s *Server) handleTraderPreflight(c *gin.Context) {
|
||||
userID := c.GetString("user_id")
|
||||
traderID := c.Param("id")
|
||||
|
||||
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
|
||||
if err != nil || fullCfg == nil || fullCfg.Trader == nil {
|
||||
SafeNotFound(c, "Trader")
|
||||
return
|
||||
}
|
||||
|
||||
result := s.runLaunchPreflight(userID, fullCfg.AIModel, fullCfg.Exchange, fullCfg.Strategy, true)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// runLaunchPreflight composes every launch readiness check. A check that
|
||||
// cannot be evaluated (RPC outage, probe timeout) reports "warning" instead
|
||||
// of "failed" so an infrastructure hiccup never hard-blocks a launch.
|
||||
func (s *Server) runLaunchPreflight(
|
||||
userID string,
|
||||
model *store.AIModel,
|
||||
exchange *store.Exchange,
|
||||
strategy *store.Strategy,
|
||||
strategyRequired bool,
|
||||
) LaunchPreflightResult {
|
||||
checks := []LaunchCheck{checkLaunchAIModel(model)}
|
||||
checks = append(checks, checkLaunchAIWallet(model)...)
|
||||
checks = append(checks, checkLaunchStrategy(strategy, strategyRequired))
|
||||
checks = append(checks, s.checkLaunchExchange(userID, exchange)...)
|
||||
|
||||
ready := true
|
||||
for _, check := range checks {
|
||||
if check.Status == launchCheckStatusFailed {
|
||||
ready = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return LaunchPreflightResult{
|
||||
Ready: ready,
|
||||
Checks: checks,
|
||||
MinAIFeeUSDC: MinAIFeeUSDC,
|
||||
MinTradingUSDC: MinTradingUSDC,
|
||||
CheckedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func checkLaunchAIModel(model *store.AIModel) LaunchCheck {
|
||||
check := LaunchCheck{ID: launchCheckAIModel}
|
||||
|
||||
switch {
|
||||
case model == nil:
|
||||
check.Status = launchCheckStatusFailed
|
||||
check.Code = "MODEL_NOT_FOUND"
|
||||
check.Message = "The selected AI model was not found. Configure an AI model first."
|
||||
case !model.Enabled:
|
||||
check.Status = launchCheckStatusFailed
|
||||
check.Code = "MODEL_DISABLED"
|
||||
check.Message = fmt.Sprintf("AI model \"%s\" is disabled. Enable it first.", model.Name)
|
||||
case strings.TrimSpace(model.APIKey.String()) == "":
|
||||
check.Status = launchCheckStatusFailed
|
||||
check.Code = "MODEL_MISSING_CREDENTIALS"
|
||||
check.Message = fmt.Sprintf("AI model \"%s\" has no credential saved. Add the API key or wallet key first.", model.Name)
|
||||
default:
|
||||
check.Status = launchCheckStatusOK
|
||||
check.Message = model.Name
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// checkLaunchAIWallet validates the claw402 fee wallet (address + Base USDC
|
||||
// balance). Non-claw402 providers pay per API key, so both checks are skipped.
|
||||
func checkLaunchAIWallet(model *store.AIModel) []LaunchCheck {
|
||||
walletCheck := LaunchCheck{ID: launchCheckAIWallet}
|
||||
fundsCheck := LaunchCheck{ID: launchCheckAIWalletFunds, Asset: "USDC", Required: MinAIFeeUSDC}
|
||||
|
||||
if model == nil || model.Provider != "claw402" || strings.TrimSpace(model.APIKey.String()) == "" {
|
||||
walletCheck.Status = launchCheckStatusSkipped
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{walletCheck, fundsCheck}
|
||||
}
|
||||
|
||||
address, err := walletAddressFromPrivateKey(model.APIKey.String())
|
||||
if err != nil {
|
||||
walletCheck.Status = launchCheckStatusFailed
|
||||
walletCheck.Code = "AI_WALLET_INVALID_KEY"
|
||||
walletCheck.Message = "The Claw402 wallet key is invalid. Recreate the Base USDC payment wallet."
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{walletCheck, fundsCheck}
|
||||
}
|
||||
|
||||
walletCheck.Status = launchCheckStatusOK
|
||||
walletCheck.Address = address
|
||||
fundsCheck.Address = address
|
||||
|
||||
balance, err := queryAIWalletBalance(address)
|
||||
if err != nil {
|
||||
fundsCheck.Status = launchCheckStatusWarning
|
||||
fundsCheck.Code = "AI_WALLET_BALANCE_UNKNOWN"
|
||||
fundsCheck.Message = "Could not verify the Base USDC balance right now. The trader will start, but AI calls fail if the wallet is empty."
|
||||
return []LaunchCheck{walletCheck, fundsCheck}
|
||||
}
|
||||
|
||||
fundsCheck.Actual = &balance
|
||||
if balance < MinAIFeeUSDC {
|
||||
fundsCheck.Status = launchCheckStatusFailed
|
||||
fundsCheck.Code = "AI_WALLET_INSUFFICIENT_FUNDS"
|
||||
fundsCheck.Message = fmt.Sprintf(
|
||||
"The Claw402 wallet holds %.2f USDC but needs at least %.0f USDC on Base to pay for AI and data calls.",
|
||||
balance, MinAIFeeUSDC,
|
||||
)
|
||||
} else {
|
||||
fundsCheck.Status = launchCheckStatusOK
|
||||
}
|
||||
|
||||
return []LaunchCheck{walletCheck, fundsCheck}
|
||||
}
|
||||
|
||||
func checkLaunchStrategy(strategy *store.Strategy, required bool) LaunchCheck {
|
||||
check := LaunchCheck{ID: launchCheckStrategy}
|
||||
|
||||
switch {
|
||||
case strategy != nil:
|
||||
check.Status = launchCheckStatusOK
|
||||
check.Message = strategy.Name
|
||||
case required:
|
||||
check.Status = launchCheckStatusFailed
|
||||
check.Code = "STRATEGY_NOT_FOUND"
|
||||
check.Message = "The selected strategy was not found. Pick or create a strategy first."
|
||||
default:
|
||||
check.Status = launchCheckStatusSkipped
|
||||
}
|
||||
|
||||
return check
|
||||
}
|
||||
|
||||
// checkLaunchExchange validates exchange configuration completeness and then
|
||||
// probes the live account (30s server cache) for status and balance.
|
||||
func (s *Server) checkLaunchExchange(userID string, exchange *store.Exchange) []LaunchCheck {
|
||||
configCheck := LaunchCheck{ID: launchCheckExchangeConfig}
|
||||
accountCheck := LaunchCheck{ID: launchCheckExchangeAccount}
|
||||
fundsCheck := LaunchCheck{ID: launchCheckExchangeFunds, Required: MinTradingUSDC}
|
||||
|
||||
if msg, code := describeExchangeConfigIssue(exchange); code != "" {
|
||||
configCheck.Status = launchCheckStatusFailed
|
||||
configCheck.Code = code
|
||||
configCheck.Message = msg
|
||||
accountCheck.Status = launchCheckStatusSkipped
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
configCheck.Status = launchCheckStatusOK
|
||||
configCheck.Message = exchangeDisplayName(exchange)
|
||||
fundsCheck.Asset = accountAssetForExchange(exchange.ExchangeType)
|
||||
|
||||
states, err := s.getExchangeAccountStates(userID)
|
||||
if err != nil {
|
||||
accountCheck.Status = launchCheckStatusWarning
|
||||
accountCheck.Code = "EXCHANGE_STATE_UNKNOWN"
|
||||
accountCheck.Message = "Could not verify the exchange account right now."
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
state, ok := states[exchange.ID]
|
||||
if !ok {
|
||||
accountCheck.Status = launchCheckStatusWarning
|
||||
accountCheck.Code = "EXCHANGE_STATE_UNKNOWN"
|
||||
accountCheck.Message = "Could not verify the exchange account right now."
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
if state.Status != exchangeAccountStatusOK {
|
||||
accountCheck.Status = launchCheckStatusFailed
|
||||
accountCheck.Code = state.ErrorCode
|
||||
accountCheck.Message = state.ErrorMessage
|
||||
if accountCheck.Message == "" {
|
||||
accountCheck.Message = fmt.Sprintf("Exchange account \"%s\" is not ready (%s).", exchangeDisplayName(exchange), state.Status)
|
||||
}
|
||||
fundsCheck.Status = launchCheckStatusSkipped
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
accountCheck.Status = launchCheckStatusOK
|
||||
accountCheck.Message = state.DisplayBalance
|
||||
|
||||
// Gate on the better of available balance and total equity: a bot with
|
||||
// capital deployed in open positions has low *available* margin but is
|
||||
// clearly funded — restarting it must not be blocked.
|
||||
funded := state.AvailableBalance
|
||||
if state.TotalEquity > funded {
|
||||
funded = state.TotalEquity
|
||||
}
|
||||
fundsCheck.Actual = &funded
|
||||
|
||||
if funded < MinTradingUSDC {
|
||||
message := fmt.Sprintf(
|
||||
"Exchange account \"%s\" holds %.2f %s but needs at least %.0f %s to place the first trade.",
|
||||
exchangeDisplayName(exchange), funded, fundsCheck.Asset, MinTradingUSDC, fundsCheck.Asset,
|
||||
)
|
||||
if exchange.Testnet {
|
||||
// Testnet balances are play money — warn instead of block.
|
||||
fundsCheck.Status = launchCheckStatusWarning
|
||||
} else {
|
||||
fundsCheck.Status = launchCheckStatusFailed
|
||||
}
|
||||
fundsCheck.Code = "EXCHANGE_INSUFFICIENT_FUNDS"
|
||||
fundsCheck.Message = message
|
||||
} else {
|
||||
fundsCheck.Status = launchCheckStatusOK
|
||||
}
|
||||
|
||||
return []LaunchCheck{configCheck, accountCheck, fundsCheck}
|
||||
}
|
||||
|
||||
// describeExchangeConfigIssue mirrors the create-time exchange validation but
|
||||
// returns stable uppercase codes for the checklist UI.
|
||||
func describeExchangeConfigIssue(exchange *store.Exchange) (string, string) {
|
||||
if exchange == nil {
|
||||
return "The selected exchange account was not found. Connect an exchange first.", "EXCHANGE_NOT_FOUND"
|
||||
}
|
||||
if !exchange.Enabled {
|
||||
return fmt.Sprintf("Exchange account \"%s\" is disabled. Enable it first.", exchangeDisplayName(exchange)), "EXCHANGE_DISABLED"
|
||||
}
|
||||
if missing := missingExchangeFields(exchange); len(missing) > 0 {
|
||||
return fmt.Sprintf(
|
||||
"Exchange account \"%s\" is missing %s. Complete the connection first.",
|
||||
exchangeDisplayName(exchange), strings.Join(missing, ", "),
|
||||
), "EXCHANGE_MISSING_FIELDS"
|
||||
}
|
||||
if exchange.ExchangeType == "hyperliquid" && !exchange.HyperliquidBuilderApproved {
|
||||
return "Hyperliquid builder authorization is not complete. Reconnect the wallet and finish the authorization.", "HYPERLIQUID_BUILDER_NOT_APPROVED"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
293
api/launch_preflight_test.go
Normal file
293
api/launch_preflight_test.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"nofx/crypto"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
// Well-known throwaway development key (hardhat account #1) — never funded.
|
||||
const testClaw402Key = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"
|
||||
|
||||
func withAIWalletBalance(t *testing.T, balance float64, err error) {
|
||||
t.Helper()
|
||||
original := queryAIWalletBalance
|
||||
queryAIWalletBalance = func(string) (float64, error) {
|
||||
return balance, err
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
queryAIWalletBalance = original
|
||||
})
|
||||
}
|
||||
|
||||
func findCheck(t *testing.T, checks []LaunchCheck, id string) LaunchCheck {
|
||||
t.Helper()
|
||||
for _, check := range checks {
|
||||
if check.ID == id {
|
||||
return check
|
||||
}
|
||||
}
|
||||
t.Fatalf("check %q not found in %+v", id, checks)
|
||||
return LaunchCheck{}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIModel(t *testing.T) {
|
||||
if got := checkLaunchAIModel(nil); got.Status != launchCheckStatusFailed || got.Code != "MODEL_NOT_FOUND" {
|
||||
t.Fatalf("nil model: expected failed/MODEL_NOT_FOUND, got %+v", got)
|
||||
}
|
||||
|
||||
disabled := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: false}
|
||||
if got := checkLaunchAIModel(disabled); got.Code != "MODEL_DISABLED" {
|
||||
t.Fatalf("disabled model: expected MODEL_DISABLED, got %+v", got)
|
||||
}
|
||||
|
||||
noKey := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true}
|
||||
if got := checkLaunchAIModel(noKey); got.Code != "MODEL_MISSING_CREDENTIALS" {
|
||||
t.Fatalf("missing key: expected MODEL_MISSING_CREDENTIALS, got %+v", got)
|
||||
}
|
||||
|
||||
ready := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
if got := checkLaunchAIModel(ready); got.Status != launchCheckStatusOK {
|
||||
t.Fatalf("ready model: expected ok, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIWalletSkipsNonClaw402(t *testing.T) {
|
||||
model := &store.AIModel{Name: "DeepSeek", Provider: "deepseek", Enabled: true, APIKey: crypto.EncryptedString("sk-test")}
|
||||
checks := checkLaunchAIWallet(model)
|
||||
if got := findCheck(t, checks, launchCheckAIWallet); got.Status != launchCheckStatusSkipped {
|
||||
t.Fatalf("non-claw402 wallet check should be skipped, got %+v", got)
|
||||
}
|
||||
if got := findCheck(t, checks, launchCheckAIWalletFunds); got.Status != launchCheckStatusSkipped {
|
||||
t.Fatalf("non-claw402 funds check should be skipped, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIWalletInvalidKey(t *testing.T) {
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString("not-a-key")}
|
||||
checks := checkLaunchAIWallet(model)
|
||||
got := findCheck(t, checks, launchCheckAIWallet)
|
||||
if got.Status != launchCheckStatusFailed || got.Code != "AI_WALLET_INVALID_KEY" {
|
||||
t.Fatalf("invalid key: expected failed/AI_WALLET_INVALID_KEY, got %+v", got)
|
||||
}
|
||||
if funds := findCheck(t, checks, launchCheckAIWalletFunds); funds.Status != launchCheckStatusSkipped {
|
||||
t.Fatalf("funds check should be skipped when the key is invalid, got %+v", funds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIWalletInsufficientFunds(t *testing.T) {
|
||||
withAIWalletBalance(t, 0.25, nil)
|
||||
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
checks := checkLaunchAIWallet(model)
|
||||
|
||||
wallet := findCheck(t, checks, launchCheckAIWallet)
|
||||
if wallet.Status != launchCheckStatusOK || wallet.Address == "" {
|
||||
t.Fatalf("wallet check should pass with derived address, got %+v", wallet)
|
||||
}
|
||||
|
||||
funds := findCheck(t, checks, launchCheckAIWalletFunds)
|
||||
if funds.Status != launchCheckStatusFailed || funds.Code != "AI_WALLET_INSUFFICIENT_FUNDS" {
|
||||
t.Fatalf("expected failed/AI_WALLET_INSUFFICIENT_FUNDS, got %+v", funds)
|
||||
}
|
||||
if funds.Actual == nil || *funds.Actual != 0.25 {
|
||||
t.Fatalf("expected actual balance 0.25, got %+v", funds.Actual)
|
||||
}
|
||||
if funds.Required != MinAIFeeUSDC {
|
||||
t.Fatalf("expected required %v, got %v", MinAIFeeUSDC, funds.Required)
|
||||
}
|
||||
if funds.Address == "" {
|
||||
t.Fatalf("funds check should carry the deposit address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIWalletRPCOutageIsWarningNotFailure(t *testing.T) {
|
||||
withAIWalletBalance(t, 0, errors.New("rpc unreachable"))
|
||||
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
checks := checkLaunchAIWallet(model)
|
||||
|
||||
funds := findCheck(t, checks, launchCheckAIWalletFunds)
|
||||
if funds.Status != launchCheckStatusWarning || funds.Code != "AI_WALLET_BALANCE_UNKNOWN" {
|
||||
t.Fatalf("RPC outage must degrade to warning, got %+v", funds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchAIWalletFunded(t *testing.T) {
|
||||
withAIWalletBalance(t, 25.5, nil)
|
||||
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
checks := checkLaunchAIWallet(model)
|
||||
|
||||
funds := findCheck(t, checks, launchCheckAIWalletFunds)
|
||||
if funds.Status != launchCheckStatusOK {
|
||||
t.Fatalf("funded wallet should pass, got %+v", funds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeExchangeConfigIssue(t *testing.T) {
|
||||
if _, code := describeExchangeConfigIssue(nil); code != "EXCHANGE_NOT_FOUND" {
|
||||
t.Fatalf("nil exchange: expected EXCHANGE_NOT_FOUND, got %s", code)
|
||||
}
|
||||
|
||||
disabled := &store.Exchange{ID: "ex", ExchangeType: "hyperliquid", Enabled: false}
|
||||
if _, code := describeExchangeConfigIssue(disabled); code != "EXCHANGE_DISABLED" {
|
||||
t.Fatalf("disabled: expected EXCHANGE_DISABLED, got %s", code)
|
||||
}
|
||||
|
||||
missing := &store.Exchange{ID: "ex", ExchangeType: "hyperliquid", Enabled: true}
|
||||
if _, code := describeExchangeConfigIssue(missing); code != "EXCHANGE_MISSING_FIELDS" {
|
||||
t.Fatalf("missing fields: expected EXCHANGE_MISSING_FIELDS, got %s", code)
|
||||
}
|
||||
|
||||
unapproved := &store.Exchange{
|
||||
ID: "ex",
|
||||
ExchangeType: "hyperliquid",
|
||||
Enabled: true,
|
||||
APIKey: crypto.EncryptedString(testClaw402Key),
|
||||
HyperliquidWalletAddr: "0x1111111111111111111111111111111111111111",
|
||||
}
|
||||
if _, code := describeExchangeConfigIssue(unapproved); code != "HYPERLIQUID_BUILDER_NOT_APPROVED" {
|
||||
t.Fatalf("builder unapproved: expected HYPERLIQUID_BUILDER_NOT_APPROVED, got %s", code)
|
||||
}
|
||||
|
||||
unapproved.HyperliquidBuilderApproved = true
|
||||
if _, code := describeExchangeConfigIssue(unapproved); code != "" {
|
||||
t.Fatalf("ready exchange: expected no issue, got %s", code)
|
||||
}
|
||||
}
|
||||
|
||||
func readyHyperliquidExchange() *store.Exchange {
|
||||
return &store.Exchange{
|
||||
ID: "ex-hl",
|
||||
ExchangeType: "hyperliquid",
|
||||
Enabled: true,
|
||||
APIKey: crypto.EncryptedString(testClaw402Key),
|
||||
HyperliquidWalletAddr: "0x1111111111111111111111111111111111111111",
|
||||
HyperliquidBuilderApproved: true,
|
||||
}
|
||||
}
|
||||
|
||||
func preflightTestServer(t *testing.T, userID string, states map[string]ExchangeAccountState) *Server {
|
||||
t.Helper()
|
||||
server := &Server{exchangeAccountStateCache: NewExchangeAccountStateCache()}
|
||||
if states != nil {
|
||||
server.exchangeAccountStateCache.Set(userID, states)
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
func TestCheckLaunchExchangeInsufficientFundsBlocks(t *testing.T) {
|
||||
exchange := readyHyperliquidExchange()
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 5.5, TotalEquity: 5.5},
|
||||
})
|
||||
|
||||
checks := server.checkLaunchExchange("user-1", exchange)
|
||||
funds := findCheck(t, checks, launchCheckExchangeFunds)
|
||||
if funds.Status != launchCheckStatusFailed || funds.Code != "EXCHANGE_INSUFFICIENT_FUNDS" {
|
||||
t.Fatalf("expected failed/EXCHANGE_INSUFFICIENT_FUNDS, got %+v", funds)
|
||||
}
|
||||
if funds.Actual == nil || *funds.Actual != 5.5 {
|
||||
t.Fatalf("expected actual 5.5, got %+v", funds.Actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchExchangeDeployedMarginPasses(t *testing.T) {
|
||||
// A running bot with capital locked in positions: low available balance
|
||||
// but healthy equity. Restart must not be blocked.
|
||||
exchange := readyHyperliquidExchange()
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 3, TotalEquity: 100},
|
||||
})
|
||||
|
||||
checks := server.checkLaunchExchange("user-1", exchange)
|
||||
funds := findCheck(t, checks, launchCheckExchangeFunds)
|
||||
if funds.Status != launchCheckStatusOK {
|
||||
t.Fatalf("deployed margin with healthy equity should pass, got %+v", funds)
|
||||
}
|
||||
if funds.Actual == nil || *funds.Actual != 100 {
|
||||
t.Fatalf("expected actual 100 (equity), got %+v", funds.Actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchExchangeTestnetLowFundsIsWarning(t *testing.T) {
|
||||
exchange := readyHyperliquidExchange()
|
||||
exchange.Testnet = true
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 0},
|
||||
})
|
||||
|
||||
checks := server.checkLaunchExchange("user-1", exchange)
|
||||
funds := findCheck(t, checks, launchCheckExchangeFunds)
|
||||
if funds.Status != launchCheckStatusWarning {
|
||||
t.Fatalf("testnet low funds should warn, not block, got %+v", funds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLaunchExchangeInvalidCredentials(t *testing.T) {
|
||||
exchange := readyHyperliquidExchange()
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {
|
||||
ExchangeID: exchange.ID,
|
||||
Status: exchangeAccountStatusInvalidCredentials,
|
||||
ErrorCode: "INVALID_CREDENTIALS",
|
||||
ErrorMessage: "Exchange credentials are invalid",
|
||||
},
|
||||
})
|
||||
|
||||
checks := server.checkLaunchExchange("user-1", exchange)
|
||||
account := findCheck(t, checks, launchCheckExchangeAccount)
|
||||
if account.Status != launchCheckStatusFailed || account.Code != "INVALID_CREDENTIALS" {
|
||||
t.Fatalf("expected failed/INVALID_CREDENTIALS, got %+v", account)
|
||||
}
|
||||
if funds := findCheck(t, checks, launchCheckExchangeFunds); funds.Status != launchCheckStatusSkipped {
|
||||
t.Fatalf("funds check should be skipped when account probe fails, got %+v", funds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLaunchPreflightAggregatesReadiness(t *testing.T) {
|
||||
withAIWalletBalance(t, 10, nil)
|
||||
|
||||
exchange := readyHyperliquidExchange()
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 100, TotalEquity: 100},
|
||||
})
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
strategy := &store.Strategy{ID: "strat-1", Name: "Autopilot"}
|
||||
|
||||
result := server.runLaunchPreflight("user-1", model, exchange, strategy, true)
|
||||
if !result.Ready {
|
||||
t.Fatalf("expected ready, got %+v", result)
|
||||
}
|
||||
if result.MinAIFeeUSDC != MinAIFeeUSDC || result.MinTradingUSDC != MinTradingUSDC {
|
||||
t.Fatalf("minimums must be exposed in the response, got %+v", result)
|
||||
}
|
||||
|
||||
// Break one prerequisite → not ready, and Summary explains it.
|
||||
withAIWalletBalance(t, 0, nil)
|
||||
result = server.runLaunchPreflight("user-1", model, exchange, strategy, true)
|
||||
if result.Ready {
|
||||
t.Fatalf("expected not ready with empty AI wallet, got %+v", result)
|
||||
}
|
||||
if result.Summary() == "" {
|
||||
t.Fatalf("summary should describe the failing check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLaunchPreflightWarningsDoNotBlock(t *testing.T) {
|
||||
withAIWalletBalance(t, 0, errors.New("rpc down"))
|
||||
|
||||
exchange := readyHyperliquidExchange()
|
||||
server := preflightTestServer(t, "user-1", map[string]ExchangeAccountState{
|
||||
exchange.ID: {ExchangeID: exchange.ID, Status: exchangeAccountStatusOK, AvailableBalance: 100},
|
||||
})
|
||||
model := &store.AIModel{Name: "Claw402", Provider: "claw402", Enabled: true, APIKey: crypto.EncryptedString(testClaw402Key)}
|
||||
|
||||
result := server.runLaunchPreflight("user-1", model, exchange, nil, false)
|
||||
if !result.Ready {
|
||||
t.Fatalf("warnings must not block launch, got %+v", result)
|
||||
}
|
||||
}
|
||||
@@ -214,6 +214,7 @@ func (s *Server) setupRoutes() {
|
||||
s.route(protected, "GET", "/vergex/signal-ranking", "Vergex signal ranking via claw402 (?marketType=all&limit=30)", s.handleVergexSignalRanking)
|
||||
s.route(protected, "GET", "/vergex/signal-lab", "Vergex signal lab via claw402 (?marketType=hip3_perp&symbol=AAPL)", s.handleVergexSignalLab)
|
||||
s.route(protected, "GET", "/vergex/cost-liquidation-heatmap", "Vergex cost/liquidation heatmap via claw402 (?marketType=hip3_perp&symbol=AAPL)", s.handleVergexCostLiquidationHeatmap)
|
||||
s.route(protected, "GET", "/vergex/flow-markets", "Vergex net-flow market ranking via claw402 (?chain=mainnet&window=1h&limit=25)", s.handleVergexFlowMarkets)
|
||||
|
||||
// AI trader management
|
||||
s.routeWithSchema(protected, "GET", "/my-traders", "List user's traders with status",
|
||||
@@ -236,8 +237,17 @@ Only include fields you want to change.`,
|
||||
`:id = trader_id from GET /api/my-traders. Stops and permanently removes the trader and all its data.`,
|
||||
s.handleDeleteTrader)
|
||||
s.routeWithSchema(protected, "POST", "/traders/:id/start", "Start trader — begins live trading",
|
||||
`:id = trader_id from GET /api/my-traders. No request body needed. The trader must have a valid exchange and AI model configured.`,
|
||||
`:id = trader_id from GET /api/my-traders. No request body needed. The trader must have a valid exchange and AI model configured.
|
||||
Runs launch preflight first and returns 400 with {"error_key":"trader.start.preflight_failed","preflight":{...}} when checks fail. Append ?force=true to skip balance gates.`,
|
||||
s.handleStartTrader)
|
||||
s.routeWithSchema(protected, "GET", "/traders/:id/preflight", "Run launch readiness checks for a trader",
|
||||
`:id = trader_id from GET /api/my-traders.
|
||||
Returns: {"ready":<bool>,"checks":[{"id":"ai_model|ai_wallet|ai_wallet_funds|strategy|exchange_config|exchange_account|exchange_funds","status":"ok|failed|warning|skipped","code":"<string>","message":"<string>","required":<number>,"actual":<number>,"asset":"<string>","address":"<string>"}],"min_ai_fee_usdc":<number>,"min_trading_usdc":<number>}`,
|
||||
s.handleTraderPreflight)
|
||||
s.routeWithSchema(protected, "POST", "/launch/preflight", "Run launch readiness checks before creating a trader",
|
||||
`Body: {"ai_model_id":"<EXACT id from GET /api/models>","exchange_id":"<EXACT id from GET /api/exchanges>","strategy_id":"<optional, EXACT id from GET /api/strategies>"}
|
||||
Returns the same shape as GET /api/traders/:id/preflight. Use this before POST /api/traders to surface every missing prerequisite at once.`,
|
||||
s.handleLaunchPreflight)
|
||||
s.routeWithSchema(protected, "POST", "/traders/:id/stop", "Stop trader — halts live trading",
|
||||
`:id = trader_id from GET /api/my-traders. No request body needed. Gracefully stops the trading loop.`,
|
||||
s.handleStopTrader)
|
||||
@@ -435,6 +445,10 @@ Returns the most recent AI decision for each symbol analyzed in the last scan cy
|
||||
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
|
||||
Returns: {"total_trades":<int>,"winning_trades":<int>,"win_rate":<float>,"total_pnl":<float>,"sharpe_ratio":<float>,"max_drawdown":<float>}`,
|
||||
s.handleStatistics)
|
||||
s.routeWithSchema(protected, "GET", "/statistics/full", "Full trade-quality metrics (win rate, profit factor, Sharpe, max drawdown)",
|
||||
`Query: ?trader_id=<EXACT trader_id from GET /api/my-traders>
|
||||
Returns: {"total_trades","win_trades","loss_trades","win_rate","profit_factor","sharpe_ratio","total_pnl","total_fee","avg_win","avg_loss","max_drawdown_pct"}`,
|
||||
s.handleStatisticsFull)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
44
docs/assets/nofx-banner.svg
Normal file
44
docs/assets/nofx-banner.svg
Normal file
@@ -0,0 +1,44 @@
|
||||
<svg width="1200" height="380" viewBox="0 0 1200 380" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<clipPath id="frame"><rect width="1200" height="380" rx="14"/></clipPath>
|
||||
<pattern id="dots" width="28" height="28" patternUnits="userSpaceOnUse">
|
||||
<circle cx="1.5" cy="1.5" r="1.2" fill="#1A1813" opacity="0.07"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<g clip-path="url(#frame)">
|
||||
<rect width="1200" height="380" fill="#F1ECE2"/>
|
||||
<rect width="1200" height="380" fill="url(#dots)"/>
|
||||
|
||||
<!-- title bar -->
|
||||
<rect width="1200" height="52" fill="#F7F4EC"/>
|
||||
<line x1="0" y1="52.5" x2="1200" y2="52.5" stroke="#1A1813" stroke-opacity="0.14"/>
|
||||
<circle cx="30" cy="26" r="6" fill="#D6433A" opacity="0.9"/>
|
||||
<circle cx="54" cy="26" r="6" fill="#B4B2A9"/>
|
||||
<circle cx="78" cy="26" r="6" fill="#2E8B57" opacity="0.9"/>
|
||||
<text x="600" y="31" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="13" letter-spacing="2" fill="#8A8478">nofx — ai trading terminal</text>
|
||||
|
||||
<!-- wordmark -->
|
||||
<text x="64" y="182" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="92" font-weight="700" letter-spacing="4" fill="#1A1813">NOFX<tspan fill="#E0483B">_</tspan></text>
|
||||
<text x="64" y="228" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="17" letter-spacing="9" fill="#6B6557">AI TRADING TERMINAL</text>
|
||||
|
||||
<text x="64" y="286" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="15" fill="#6B6557">Language models make the decisions.</text>
|
||||
<text x="64" y="312" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="15" fill="#6B6557">A Go runtime enforces the limits.</text>
|
||||
|
||||
<!-- divider -->
|
||||
<line x1="672" y1="84" x2="672" y2="344" stroke="#1A1813" stroke-opacity="0.12"/>
|
||||
|
||||
<!-- session log -->
|
||||
<g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" font-size="14.5">
|
||||
<text x="712" y="112" fill="#8A8478">$ nofx start</text>
|
||||
<text x="712" y="146"><tspan fill="#2E8B57">✓</tspan><tspan fill="#1A1813" dx="10">preflight passed</tspan><tspan fill="#8A8478" dx="10">7/7 checks</tspan></text>
|
||||
<text x="712" y="180"><tspan fill="#2E8B57">✓</tspan><tspan fill="#1A1813" dx="10">trader online</tspan><tspan fill="#8A8478" dx="10">deepseek · hyperliquid</tspan></text>
|
||||
<text x="712" y="222"><tspan fill="#8A8478">decision</tspan><tspan fill="#E0483B" dx="12" font-weight="700">OPEN_LONG</tspan><tspan fill="#1A1813" dx="12">BTC ×3</tspan></text>
|
||||
<text x="712" y="252"><tspan fill="#8A8478">reason</tspan><tspan fill="#6B6557" dx="12">oi rising · funding neutral · 4h breakout</tspan></text>
|
||||
<text x="712" y="282"><tspan fill="#8A8478">risk</tspan><tspan fill="#6B6557" dx="12">sl −2.5% · tp +5.0% · placed on exchange</tspan></text>
|
||||
<text x="712" y="324"><tspan fill="#8A8478">equity</tspan><tspan fill="#1A1813" dx="12">$12,847</tspan><tspan fill="#8A8478" dx="18">pnl</tspan><tspan fill="#2E8B57" dx="12">+4.2%</tspan></text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<rect x="0.5" y="0.5" width="1199" height="379" rx="14" fill="none" stroke="#1A1813" stroke-opacity="0.22"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -1,22 +1,14 @@
|
||||
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a> によるバックアップ</strong></p>
|
||||
|
||||
<h1 align="center">NOFX</h1>
|
||||
<p align="center"><strong><a href="https://vergex.trade">vergex.trade</a> の支援を受けています</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<strong>グローバル市場向け AI トレーディングターミナル。</strong><br/>
|
||||
<strong>米国株、コモディティ、FX、暗号資産のリサーチ、戦略生成、執行、モニタリング。</strong>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI トレーディングターミナル" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,97 +21,83 @@
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
NOFX は、マーケットリサーチ、戦略開発、取引執行、ポートフォリオ監視をひとつのワークスペースで行うためのオープンソース AI トレーディングターミナルです。
|
||||
NOFX は、戦略そのものが言語モデルであるオープンソースのトレーディングターミナルです。各トレーダーは、市場構造を読み取り、判断し、執行し、その根拠を記録するという連続的なループを回し続けます。その間、Go ランタイムがすべての注文を、モデルには上書きできないハードなリスク制限内に抑え込みます。
|
||||
|
||||
対象は米国株、コモディティ契約、FX ペア、デジタル資産などの高流動性グローバル市場です。AI レイヤーは取引意図をウォッチリスト、シグナル、戦略ロジック、リスク制御、執行ワークフローへ変換します。
|
||||
トレーダーの構成は自由です。任意のモデル、9 つの取引所のいずれか、任意の戦略を組み合わせられます。複数のトレーダーを並行して走らせ、実現リターンに基づく公開リーダーボードで比較できます。すべては自分のマシン上で動作し、取引所の認証情報は保存時に暗号化され、外部に送信されることはありません。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
**http://127.0.0.1:3000** を開きます。
|
||||
ターミナルは `http://127.0.0.1:3000` で開きます。
|
||||
|
||||
---
|
||||
**初回起動**
|
||||
|
||||
## 取引所登録
|
||||
1. 登録します — 最初に作成したアカウントがこのインスタンスのオーナーになります。
|
||||
2. ガイド付きの起動手順に従います。自動作成される AI 手数料ウォレットに **$1+ USDC**(Base ネットワーク)を入金し、続いて Hyperliquid を接続して取引資金として **$12+ USDC** を入金します。
|
||||
3. **Autopilot** を開始します。AI は数分ごとに市場をスキャンして自律的に取引し、すべての判断はその場でダッシュボードに表示されます。ワンクリックでいつでも停止できます。
|
||||
|
||||
以下のリンクから、暗号資産および対応する米国株、FX、コモディティデリバティブ市場向けの取引口座を開設できます。これらは NOFX のパートナープログラム経由で、手数料割引または紹介特典が適用される場合があります。
|
||||
<br/>
|
||||
|
||||
| 取引所 | 状態 | 手数料割引付き登録 |
|
||||
| :--- | :---: | :--- |
|
||||
| <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) |
|
||||
## 取引所の登録
|
||||
|
||||
---
|
||||
NOFX は無料のオープンソースソフトウェアです。以下のパートナーリンク経由で口座を開設すると、取引手数料の割引が受けられるうえ、継続的な開発の資金にもなります。
|
||||
|
||||
## クイックデモ
|
||||
| 取引所 | 状態 | 手数料割引付きで登録 |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [登録](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [登録](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [登録](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [登録](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [登録](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [登録](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [登録](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [登録](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [登録](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
<p align="center">
|
||||
<a href="https://drive.google.com/file/d/1frzw-HDZ3viQvLOQKsAJGc9bT0dXs68D/view">
|
||||
<img src="../../../screenshots/demo-cover.png" alt="NOFX quick demo video" width="900"/>
|
||||
</a>
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
カバー画像をクリックしてデモ動画をご覧ください。
|
||||
</p>
|
||||
## デモ
|
||||
|
||||
---
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
## 市場
|
||||
<br/>
|
||||
|
||||
**米国株 · コモディティ · FX · 暗号資産**
|
||||
## モデルは提案する。ランタイムが決める。
|
||||
|
||||
NOFX は単一取引所の画面ではなく、マルチアセットのリサーチ、戦略構築、執行、監視ワークフローを中心に設計されています。
|
||||
判断は、[Claw402.ai](https://claw402.ai)・Vergex のデータスタックを読み取る言語モデルから生まれます。全市場を方向バイアスとシグナル強度でランク付けするライブシグナルボード、銘柄ごとの Signal Lab ディープシグナル、群衆の「燃料」と「壁」の位置を映すコストベース・清算ヒートマップ、リアルタイムのネットフロー — これらを生のローソク足とトレーダー自身のライブ実績と突き合わせます。ただし、執行はそうではありません。
|
||||
|
||||
---
|
||||
すべての注文は、モデルの手が届かない、コードで強制されるリスク制限を通過します。
|
||||
|
||||
## AI モデルアクセス
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| ポジション制限 | 同時ポジション数の上限、想定元本は口座資産に対する比率で制限、1 シンボルにつき 1 ポジション |
|
||||
| レバレッジ制限 | モデルの要求とは無関係に、注文サイズの算出時にハードキャップを適用 |
|
||||
| 取引所側の保護 | すべてのエントリー直後に、ストップロスとテイクプロフィットを取引所側に設置 |
|
||||
| ドローダウン自動クローズ | ピークから利益を大きく吐き出したポジションは自動的にクローズ |
|
||||
| 取引スロットリング | 最低保有時間、シンボルごとの再エントリークールダウン、サイクルごと・1 時間ごとのエントリー回数制限 |
|
||||
| セーフモード | モデルの失敗が繰り返された場合、モデルが回復するまで新規エントリーをブロック |
|
||||
| 起動前チェック | トレーダーの開始前に、モデルアクセス、ウォレット資金、戦略、取引所残高を検証 |
|
||||
|
||||
NOFX は AI 推論を [Claw402](https://claw402.ai) 経由で自動ルーティングします。ユーザーはモデルプロバイダーの設定、API キー管理、個別 AI アカウントの維持を行う必要がありません。ターミナルは Claw402 の従量課金インフラを使って対応モデルへオンデマンドにアクセスし、公式割引チャネルを通じてルーティングします。
|
||||
各判断は、モデルの推論全文とともに保存されます。記録の残らないポジションは存在しません。
|
||||
|
||||
| プロバイダー | アクセス |
|
||||
<br/>
|
||||
|
||||
## ターミナル
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Claw402** | [公式割引で従量課金 AI モデルにアクセス](https://claw402.ai) |
|
||||
|
||||
---
|
||||
|
||||
## 機能
|
||||
|
||||
| 機能 | 説明 |
|
||||
| :--- | :--- |
|
||||
| **AI トレーディングターミナル** | 米国株、コモディティ、FX、暗号資産向けの統合ワークスペース |
|
||||
| **AI モデルアクセス** | Claw402 経由で対応プロバイダーへ自動接続 |
|
||||
| **取引所接続** | Binance、Bybit、OKX、Hyperliquid、Bitget、KuCoin、Gate、Aster、Lighter |
|
||||
| **Strategy Studio** | 市場ユニバース、インジケーター、リスク制御、戦略ロジック |
|
||||
| **モデル競争** | AI トレーダーのライブ成績とランキングを比較 |
|
||||
| **Telegram Agent** | チャットからトレーディングアシスタントを操作・監視 |
|
||||
| **ポートフォリオダッシュボード** | ポジション、損益、執行履歴、モデル判断ログ |
|
||||
|
||||
---
|
||||
|
||||
## スクリーンショット
|
||||
| **Autopilot** | ガイド付きの起動フロー:資金投入、接続、入金、開始 — 全工程をサーバー側の事前チェックが支えます |
|
||||
| **Strategy Studio** | スタイルプリセット、コインユニバース、インジケーター、レバレッジ、エントリー確信度、カスタムプロンプト |
|
||||
| **Competition** | 実現リターンでランク付けされる公開リーダーボード。各エントリーには使用モデルが明記されます |
|
||||
| **Dashboard** | ライブのポジション、注文、統計、そしてすべての判断の背後にある推論 |
|
||||
|
||||
<details>
|
||||
<summary><b>設定ページ</b></summary>
|
||||
<summary>スクリーンショット</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>
|
||||
<br/>
|
||||
|
||||
| 概要 | マーケットチャート |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
@@ -129,131 +107,51 @@ NOFX は AI 推論を [Claw402](https://claw402.ai) 経由で自動ルーティ
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <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"/> |
|
||||
| コンペティション | 設定 |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## インストール
|
||||
## モデル
|
||||
|
||||
### Linux / macOS
|
||||
自分の API キーで 8 つのプロバイダーを利用できます — DeepSeek、OpenAI、Claude、Qwen、Gemini、Grok、Kimi、MiniMax。カスタムエンドポイントとカスタムモデル名にも対応しています。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
あるいは、キーを一切使わない方法もあります。[Claw402](https://claw402.ai) は x402 プロトコル上で、モデルの利用量を呼び出しごとに USDC で課金します。Base 上のウォレットひとつが、すべての API キーの代わりになります。
|
||||
|
||||
### Railway(クラウド)
|
||||
| プロバイダー | アクセス |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [公式割引付きの従量課金 AI モデル](https://claw402.ai) |
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
## マーケット
|
||||
|
||||
### Docker
|
||||
9 つの取引所すべてで暗号資産の無期限先物を取引できます。Hyperliquid では、同じランタイムが暗号資産に加えて、トークン化された米国株、コモディティ、株価指数、FX、プレ IPO 無期限先物 — TSLA、NVDA、GOLD、SPX、EUR、OPENAI — も取引します。
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
[Docker Desktop](https://www.docker.com/products/docker-desktop/) をインストールしてから:
|
||||
|
||||
```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
|
||||
# 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
|
||||
```
|
||||
|
||||
### アップデート
|
||||
|
||||
```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` を設定
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
@@ -261,50 +159,99 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## インストール
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
**Windows** — [Docker Desktop](https://www.docker.com/products/docker-desktop/) をインストールしてから、次を実行します。
|
||||
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
**ソースからビルド** — Go 1.21+、Node.js 18+ が必要です。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
**アップデート** — インストールスクリプトを再実行すると、その場でアップグレードされます。
|
||||
|
||||
<details>
|
||||
<summary>サーバーへのデプロイ</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**Cloudflare 経由の HTTPS**
|
||||
|
||||
1. [Cloudflare](https://dash.cloudflare.com)(無料プラン)にドメインを追加
|
||||
2. A レコード → サーバー IP(Proxied を有効化)
|
||||
3. SSL/TLS → Flexible
|
||||
4. `.env` に `TRANSPORT_ENCRYPTION=true` を設定
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## ドキュメント
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| [アーキテクチャ](../../architecture/README.md) | システム設計とモジュール索引 |
|
||||
| | |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [はじめに](../../getting-started/README.md) | デプロイと取引所 API のガイド |
|
||||
| [アーキテクチャ](../../architecture/README.md) | システム設計とモジュール索引 |
|
||||
| [戦略モジュール](../../architecture/STRATEGY_MODULE.md) | 銘柄選択、AI プロンプト、執行 |
|
||||
| [FAQ](../../faq/README.md) | よくある質問 |
|
||||
| [はじめに](../../getting-started/README.md) | デプロイガイド |
|
||||
| [FAQ](../../guides/faq.en.md) | よくある質問 |
|
||||
| [トラブルシューティング](../../guides/TROUBLESHOOTING.md) | よくある問題の診断 |
|
||||
|
||||
---
|
||||
## コミュニティ
|
||||
|
||||
## 貢献
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [ライブダッシュボード](https://vergex.trade/explore)
|
||||
|
||||
[貢献ガイド](../../../CONTRIBUTING.md)、[行動規範](../../../CODE_OF_CONDUCT.md)、[セキュリティポリシー](../../../SECURITY.md) を参照してください。
|
||||
## コントリビューション
|
||||
|
||||
### 貢献者プログラム
|
||||
コード、ドキュメント、翻訳、バグ報告のいずれも歓迎します。詳細は[貢献ガイド](../../../CONTRIBUTING.md)、[行動規範](../../../CODE_OF_CONDUCT.md)、[セキュリティポリシー](../../../SECURITY.md)をご覧ください。
|
||||
|
||||
NOFX は有意義な貢献を記録し、エコシステムの成長に応じて貢献者へ還元する予定です。優先 Issue は高い報酬ウェイトを持ちます。
|
||||
NOFX は有意義な貢献を記録しており、エコシステムの成長に応じて貢献者に還元していく予定です。優先度の高い Issue にはより高いウェイトが付きます。
|
||||
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
| 貢献の種類 | ウェイト |
|
||||
| :---------------- | :----: |
|
||||
| ピン留め Issue の PR | ★★★★★★ |
|
||||
| コード(マージ済み PR) | ★★★★★ |
|
||||
| バグ修正 | ★★★★ |
|
||||
| 機能アイデア | ★★★ |
|
||||
| バグ報告 | ★★ |
|
||||
| ドキュメント | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## リンク
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| 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) |
|
||||
|
||||
> **リスク警告**:自動売買には大きなリスクがあります。適切なポジションサイズを守り、各取引所の仕組みを理解し、失ってもよい資金だけを使用してください。
|
||||
|
||||
---
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## スポンサー
|
||||
|
||||
@@ -322,8 +269,14 @@ NOFX は有意義な貢献を記録し、エコシステムの成長に応じて
|
||||
|
||||
[スポンサーになる](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
## License
|
||||
<br/>
|
||||
|
||||
NOFX が役に立ったら、スターを付けていただけると、他のトレーダーがこのプロジェクトを見つけやすくなります。
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## ライセンス
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>自動売買には大きなリスクが伴います。AI 駆動の戦略は実験的なものであり、損失を出す可能性があります。適切なポジションサイズを守り、各取引所の仕組みを理解し、失っても差し支えのない資金以外では決して取引しないでください。詳細は[免責事項](../../../DISCLAIMER.md)をご覧ください。</sub>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<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>미국 주식, 원자재, 외환, 암호화폐 리서치, 전략 생성, 실행, 모니터링.</strong>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,97 +21,83 @@
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
NOFX는 시장 리서치, 전략 개발, 거래 실행, 포트폴리오 모니터링을 하나의 워크스페이스에서 처리하는 오픈소스 AI 트레이딩 터미널입니다.
|
||||
NOFX는 언어 모델이 곧 전략이 되는 오픈소스 트레이딩 터미널입니다. 각 트레이더는 시장 구조 읽기, 판단, 실행, 근거 기록으로 이어지는 루프를 끊임없이 반복하고, Go 런타임은 모든 주문을 모델이 절대 우회할 수 없는 하드 리스크 한도 안에 묶어 둡니다.
|
||||
|
||||
제품은 미국 주식, 원자재 계약, 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`에서 열립니다.
|
||||
|
||||
---
|
||||
**첫 실행**
|
||||
|
||||
1. 계정을 등록합니다 — 첫 번째 계정이 해당 인스턴스의 소유자가 됩니다.
|
||||
2. 가이드 런치를 따라갑니다. 자동으로 생성된 AI 수수료 지갑에 **$1+ USDC**(Base 네트워크)를 넣은 뒤, Hyperliquid를 연결하고 거래 자금으로 **$12+ USDC**를 입금합니다.
|
||||
3. **Autopilot**을 시작합니다. AI가 몇 분마다 시장을 스캔하며 스스로 거래하고, 모든 결정은 발생하는 즉시 대시보드에 표시됩니다. 언제든 클릭 한 번으로 중지할 수 있습니다.
|
||||
|
||||
<br/>
|
||||
|
||||
## 거래소 등록
|
||||
|
||||
아래 링크를 통해 암호화폐와 지원되는 미국 주식, FX, 원자재 파생상품 시장용 거래 계정을 개설할 수 있습니다. 이 링크는 NOFX 파트너 프로그램을 통해 제공되며 수수료 할인 또는 추천 혜택이 포함될 수 있습니다.
|
||||
NOFX는 무료 오픈소스입니다. 아래 파트너 링크로 계정을 개설하면 거래 수수료가 할인되며, 프로젝트의 지속적인 개발에도 보탬이 됩니다.
|
||||
|
||||
| 거래소 | 상태 | 수수료 할인 등록 |
|
||||
| :--- | :---: | :--- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [등록](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [등록](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [등록](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [등록](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [등록](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [등록](https://app.lighter.xyz/?referral=68151432) |
|
||||
| 거래소 | 상태 | 수수료 할인 등록 |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [등록](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [등록](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [등록](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [등록](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [등록](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [등록](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [등록](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [등록](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [등록](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## 빠른 데모
|
||||
## 데모
|
||||
|
||||
<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>
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
<p align="center">
|
||||
커버 이미지를 클릭해 데모 영상을 보세요.
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
---
|
||||
## 모델은 제안하고, 런타임이 결정합니다
|
||||
|
||||
## 시장
|
||||
의사결정은 [Claw402.ai](https://claw402.ai) · Vergex 데이터 스택을 읽는 언어 모델에서 나옵니다. 모든 시장을 방향 편향과 시그널 강도로 순위 매기는 실시간 시그널 보드, 종목별 Signal Lab 딥 시그널, 시장의 '연료'와 '벽'이 어디에 있는지 보여주는 코스트·청산 히트맵, 실시간 순유입 — 이를 원시 캔들과 트레이더 자신의 실시간 트랙 레코드로 교차 검증합니다. 그러나 실행은 그렇지 않습니다.
|
||||
|
||||
**미국 주식 · 원자재 · 외환 · 암호화폐**
|
||||
모든 주문은 모델의 손이 닿지 않는 곳에서 코드로 강제되는 한도를 통과해야 합니다.
|
||||
|
||||
NOFX는 단일 거래소 화면이 아니라 멀티에셋 리서치, 전략 구축, 실행, 모니터링 워크플로를 중심으로 구성됩니다.
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| 포지션 한도 | 최대 동시 포지션 수, 자본 대비 비율로 상한이 걸린 명목 금액, 심볼당 포지션 1개 |
|
||||
| 레버리지 클램프 | 모델이 무엇을 요청하든 관계없이 주문 크기 산정 시점에 적용되는 하드 캡 |
|
||||
| 거래소 측 보호 | 모든 진입 직후 거래소에 스탑로스와 테이크프로핏을 즉시 배치 |
|
||||
| 드로다운 자동 청산 | 고점 대비 이익을 과도하게 반납한 수익 포지션은 자동으로 청산 |
|
||||
| 거래 스로틀링 | 최소 보유 시간, 심볼별 재진입 쿨다운, 사이클당·시간당 진입 횟수 제한 |
|
||||
| 세이프 모드 | 모델 오류가 반복되면 모델이 복구될 때까지 신규 진입을 차단 |
|
||||
| 런치 프리플라이트 | 트레이더 시작 전에 모델 접근성, 지갑 자금, 전략, 거래소 잔고를 검증 |
|
||||
|
||||
---
|
||||
각 결정은 모델의 전체 추론 과정과 함께 저장됩니다. 기록 없는 포지션은 존재하지 않습니다.
|
||||
|
||||
## AI 모델 액세스
|
||||
<br/>
|
||||
|
||||
NOFX는 AI 추론을 [Claw402](https://claw402.ai)를 통해 자동 라우팅합니다. 사용자는 모델 제공업체를 설정하거나 API 키를 관리하거나 별도 AI 계정을 유지할 필요가 없습니다. 터미널은 Claw402의 사용량 기반 인프라를 통해 지원 모델에 온디맨드로 접근하며 공식 할인 채널로 트래픽을 라우팅합니다.
|
||||
## 터미널
|
||||
|
||||
| 제공업체 | 액세스 |
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Claw402** | [공식 할인으로 사용량 기반 AI 모델 이용](https://claw402.ai) |
|
||||
|
||||
---
|
||||
|
||||
## 기능
|
||||
|
||||
| 기능 | 설명 |
|
||||
| :--- | :--- |
|
||||
| **AI 트레이딩 터미널** | 미국 주식, 원자재, 외환, 암호화폐 워크플로를 위한 통합 워크스페이스 |
|
||||
| **AI 모델 액세스** | Claw402를 통해 지원 모델 제공업체에 자동 연결 |
|
||||
| **거래소 연결** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, Lighter |
|
||||
| **Strategy Studio** | 시장 유니버스, 지표, 리스크 제어, 전략 로직 |
|
||||
| **모델 경쟁** | AI 트레이더의 실시간 성과와 리더보드 비교 |
|
||||
| **Telegram Agent** | 채팅으로 트레이딩 어시스턴트 제어 및 모니터링 |
|
||||
| **포트폴리오 대시보드** | 포지션, 손익, 실행 기록, 모델 의사결정 로그 |
|
||||
|
||||
---
|
||||
|
||||
## 스크린샷
|
||||
| **Autopilot** | 가이드 런치: 자금 입금, 연결, 예치, 시작 — 전 과정에 서버 측 프리플라이트 적용 |
|
||||
| **Strategy Studio** | 스타일 프리셋, 코인 유니버스, 지표, 레버리지, 진입 신뢰도, 커스텀 프롬프트 |
|
||||
| **경쟁** | 실현 수익률로 순위가 매겨지는 공개 리더보드, 각 항목마다 사용한 모델 표기 |
|
||||
| **대시보드** | 실시간 포지션, 주문, 통계, 그리고 모든 결정 뒤에 있는 추론 과정 |
|
||||
|
||||
<details>
|
||||
<summary><b>설정 페이지</b></summary>
|
||||
<summary>스크린샷</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>
|
||||
<br/>
|
||||
|
||||
| 개요 | 마켓 차트 |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
@@ -129,131 +107,51 @@ NOFX는 AI 추론을 [Claw402](https://claw402.ai)를 통해 자동 라우팅합
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <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"/> |
|
||||
| 경쟁 | 설정 |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## 설치
|
||||
## 모델
|
||||
|
||||
### Linux / macOS
|
||||
자신의 키로 사용할 수 있는 여덟 개 제공업체 — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — 커스텀 엔드포인트와 모델 이름도 지원합니다.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
키가 전혀 없어도 됩니다. [Claw402](https://claw402.ai)는 x402 프로토콜을 통해 모델 사용량을 호출 단위로 USDC로 정산합니다. Base 네트워크의 지갑 하나가 모든 API 키를 대신합니다.
|
||||
|
||||
### Railway(클라우드)
|
||||
| 제공업체 | 이용 방법 |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [공식 할인이 적용된 종량제 AI 모델](https://claw402.ai) |
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
## 시장
|
||||
|
||||
### Docker
|
||||
아홉 개 거래소 전체에서 암호화폐 무기한 선물을 거래합니다. Hyperliquid에서는 같은 런타임이 암호화폐와 함께 토큰화된 미국 주식, 원자재, 지수, 외환, 프리 IPO 무기한 선물 — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — 도 거래합니다.
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
[Docker Desktop](https://www.docker.com/products/docker-desktop/) 설치 후:
|
||||
|
||||
```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
|
||||
# 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
|
||||
```
|
||||
|
||||
### 업데이트
|
||||
|
||||
```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` 설정
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
@@ -261,50 +159,99 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## 설치
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
**Windows** — [Docker Desktop](https://www.docker.com/products/docker-desktop/)을 설치한 뒤:
|
||||
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
**소스에서 빌드** — Go 1.21+, Node.js 18+:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
**업데이트** — 설치 스크립트를 다시 실행하면 제자리에서 업그레이드됩니다.
|
||||
|
||||
<details>
|
||||
<summary>서버 배포</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**Cloudflare를 통한 HTTPS**
|
||||
|
||||
1. [Cloudflare](https://dash.cloudflare.com)에 도메인 추가(무료 플랜)
|
||||
2. A 레코드 → 서버 IP, Proxied 설정
|
||||
3. SSL/TLS → Flexible
|
||||
4. `.env`에 `TRANSPORT_ENCRYPTION=true` 설정
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## 문서
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| [아키텍처](../../architecture/README.md) | 시스템 설계와 모듈 색인 |
|
||||
| [전략 모듈](../../architecture/STRATEGY_MODULE.md) | 종목 선택, AI 프롬프트, 실행 |
|
||||
| [FAQ](../../faq/README.md) | 자주 묻는 질문 |
|
||||
| [시작하기](../../getting-started/README.md) | 배포 가이드 |
|
||||
| | |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [시작하기](../../getting-started/README.md) | 배포 및 거래소 API 가이드 |
|
||||
| [아키텍처](../../architecture/README.md) | 시스템 설계와 모듈 색인 |
|
||||
| [전략 모듈](../../architecture/STRATEGY_MODULE.md) | 코인 선정, AI 프롬프트, 실행 |
|
||||
| [FAQ](../../guides/faq.en.md) | 자주 묻는 질문 |
|
||||
| [문제 해결](../../guides/TROUBLESHOOTING.md) | 흔한 문제 진단하기 |
|
||||
|
||||
---
|
||||
## 커뮤니티
|
||||
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [라이브 대시보드](https://vergex.trade/explore)
|
||||
|
||||
## 기여
|
||||
|
||||
[기여 가이드](../../../CONTRIBUTING.md), [행동 강령](../../../CODE_OF_CONDUCT.md), [보안 정책](../../../SECURITY.md)을 확인하세요.
|
||||
코드, 문서, 번역, 버그 리포트 모두 환영합니다 — [기여 가이드](../../../CONTRIBUTING.md), [행동 강령](../../../CODE_OF_CONDUCT.md), [보안 정책](../../../SECURITY.md)을 참고하세요.
|
||||
|
||||
### 기여자 프로그램
|
||||
NOFX는 의미 있는 기여를 기록하며, 생태계가 성장함에 따라 기여자에게 보상할 계획입니다. 우선순위 이슈에는 더 높은 가중치가 부여됩니다.
|
||||
|
||||
NOFX는 의미 있는 기여를 기록하며 생태계 성장에 따라 기여자에게 보상할 계획입니다. 우선순위 이슈는 더 높은 보상 가중치를 가집니다.
|
||||
| 기여 유형 | 가중치 |
|
||||
| :---------------- | :----: |
|
||||
| 고정 이슈 PR | ★★★★★★ |
|
||||
| 코드(머지된 PR) | ★★★★★ |
|
||||
| 버그 수정 | ★★★★ |
|
||||
| 기능 아이디어 | ★★★ |
|
||||
| 버그 리포트 | ★★ |
|
||||
| 문서 | ★★ |
|
||||
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## 링크
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| 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) |
|
||||
|
||||
> **위험 고지**: 자동매매에는 상당한 위험이 따릅니다. 적절한 포지션 규모를 사용하고 각 거래소 구조를 이해하며 감당 가능한 자금만 거래하세요.
|
||||
|
||||
---
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## 스폰서
|
||||
|
||||
@@ -322,8 +269,14 @@ NOFX는 의미 있는 기여를 기록하며 생태계 성장에 따라 기여
|
||||
|
||||
[스폰서 되기](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
## License
|
||||
<br/>
|
||||
|
||||
NOFX가 도움이 되었다면, 스타 하나가 다른 트레이더들이 이 프로젝트를 발견하는 데 큰 힘이 됩니다.
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## 라이선스
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>자동매매에는 상당한 위험이 따릅니다. AI 기반 전략은 실험적이며 손실이 발생할 수 있습니다. 포지션 규모를 적절히 관리하고, 각 거래소의 구조를 이해하며, 잃어도 감당할 수 있는 자금으로만 거래하세요. 전체 [면책 조항](../../../DISCLAIMER.md)을 확인하세요.</sub>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<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>Исследования, генерация стратегий, исполнение и мониторинг для акций США, сырьевых товаров, FX и криптоактивов.</strong>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,231 +21,137 @@
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
NOFX — open-source AI-терминал для активных трейдеров, которым нужен единый рабочий контур для анализа рынков, разработки стратегий, исполнения сделок и мониторинга портфеля.
|
||||
NOFX — торговый терминал с открытым исходным кодом, где стратегией выступает языковая модель. Каждый трейдер работает в непрерывном цикле — читает структуру рынка, принимает решение, исполняет его и записывает ход рассуждений, — а рантайм на Go прижимает каждый ордер к жёстким риск-лимитам, которые модель не может обойти.
|
||||
|
||||
Продукт ориентирован на ликвидные глобальные рынки: акции США, товарные контракты, валютные пары и цифровые активы. 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`.
|
||||
|
||||
---
|
||||
**Первый запуск**
|
||||
|
||||
1. Зарегистрируйтесь — первый аккаунт становится владельцем экземпляра.
|
||||
2. Пройдите пошаговый запуск: положите **$1+ USDC** (сеть Base) в кошелёк для оплаты AI, который он создаст для вас, затем подключите Hyperliquid и внесите **$12+ USDC** для торговли.
|
||||
3. Запустите **Autopilot**. AI сканирует рынок каждые несколько минут и торгует самостоятельно; каждое решение появляется на дашборде в момент принятия. Остановить можно в любой момент одним кликом.
|
||||
|
||||
<br/>
|
||||
|
||||
## Регистрация на биржах
|
||||
|
||||
Используйте ссылки ниже для открытия торговых счетов на крипторынках и поддерживаемых рынках деривативов на акции США, FX и сырьевые товары. Эти маршруты относятся к партнерским программам NOFX и могут включать скидки на комиссии или реферальные преимущества.
|
||||
NOFX бесплатен и открыт. Открытие счёта по партнёрским ссылкам ниже даёт сниженные торговые комиссии и поддерживает дальнейшую разработку.
|
||||
|
||||
| Биржа | Статус | Регистрация со скидкой |
|
||||
| :--- | :---: | :--- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Регистрация](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Регистрация](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Регистрация](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Регистрация](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Регистрация](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Регистрация](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Регистрация](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Регистрация](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Регистрация](https://app.lighter.xyz/?referral=68151432) |
|
||||
| Биржа | Статус | Регистрация со скидкой на комиссии |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Зарегистрироваться](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Зарегистрироваться](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Зарегистрироваться](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Зарегистрироваться](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Зарегистрироваться](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Зарегистрироваться](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Зарегистрироваться](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Зарегистрироваться](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Зарегистрироваться](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Короткая демонстрация
|
||||
## Демо
|
||||
|
||||
<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>
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
<p align="center">
|
||||
Нажмите на обложку, чтобы посмотреть демо.
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
---
|
||||
## Модель предполагает. Рантайм располагает.
|
||||
|
||||
## Рынки
|
||||
Решения принимает языковая модель, читающая стек данных [Claw402.ai](https://claw402.ai) · Vergex: живой сигнальный борд, ранжирующий каждый рынок по направлению и силе сигнала, глубокие сигналы Signal Lab по каждому инструменту, тепловые карты себестоимости и ликвидаций, показывающие, где у толпы «топливо» и «стены», и чистый поток средств в реальном времени — всё это сверяется с сырыми свечами и собственным живым трек-рекордом трейдера. Исполнение — нет.
|
||||
|
||||
**Акции США · Сырьевые товары · FX · Криптоактивы**
|
||||
Каждый ордер проходит через лимиты, зашитые в код и недоступные модели:
|
||||
|
||||
NOFX организует исследования, построение стратегий, исполнение и мониторинг вокруг мультиактивных рабочих процессов, а не вокруг одного торгового экрана.
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| Лимиты позиций | Максимум одновременных позиций, номинал ограничен долей от капитала, одна позиция на инструмент |
|
||||
| Ограничение плеча | Жёсткие пределы применяются при расчёте размера ордера, независимо от того, что запросила модель |
|
||||
| Защита на стороне биржи | Стоп-лосс и тейк-профит выставляются на бирже сразу после каждого входа |
|
||||
| Автозакрытие по просадке | Прибыльные позиции, отдавшие слишком много от своего пика, закрываются |
|
||||
| Троттлинг сделок | Минимальное время удержания, кулдауны повторного входа по инструменту, лимиты входов на цикл и на час |
|
||||
| Безопасный режим | Повторяющиеся сбои модели блокируют новые входы, пока модель не восстановится |
|
||||
| Предстартовая проверка | Доступ к модели, средства в кошельке, стратегия и балансы бирж проверяются до того, как трейдер сможет стартовать |
|
||||
|
||||
---
|
||||
Каждое решение сохраняется вместе с полным ходом рассуждений модели. Ни одной позиции без документального следа.
|
||||
|
||||
## Доступ к AI-моделям
|
||||
<br/>
|
||||
|
||||
NOFX автоматически маршрутизирует AI-инференс через [Claw402](https://claw402.ai). Пользователям не нужно настраивать провайдеров моделей, управлять API-ключами или поддерживать отдельные AI-аккаунты. Терминал обращается к поддерживаемым моделям по требованию через pay-as-you-go инфраструктуру Claw402 и официальный канал со скидкой.
|
||||
## Терминал
|
||||
|
||||
| Провайдер | Доступ |
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Claw402** | [Доступ к AI-моделям по мере использования с официальной скидкой](https://claw402.ai) |
|
||||
|
||||
---
|
||||
|
||||
## Возможности
|
||||
|
||||
| Возможность | Описание |
|
||||
| :--- | :--- |
|
||||
| **AI trading terminal** | Единое рабочее пространство для акций США, сырья, FX и криптоактивов |
|
||||
| **Доступ к AI-моделям** | Автоматический доступ к поддерживаемым провайдерам через Claw402 |
|
||||
| **Подключение бирж** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, Lighter |
|
||||
| **Strategy Studio** | Рыночные универсумы, индикаторы, риск-контроль и логика стратегий |
|
||||
| **Соревнование моделей** | Сравнение AI-трейдеров по live-результатам и таблице лидеров |
|
||||
| **Telegram Agent** | Управление и мониторинг ассистента через чат |
|
||||
| **Портфельный дашборд** | Позиции, P/L, история исполнения и логи решений модели |
|
||||
|
||||
---
|
||||
|
||||
## Скриншоты
|
||||
| **Autopilot** | Пошаговый запуск: пополнить, подключить, внести депозит, стартовать — с серверной предстартовой проверкой на каждом шаге |
|
||||
| **Strategy Studio** | Пресеты стилей, наборы монет, индикаторы, плечо, порог уверенности для входа, собственные промпты |
|
||||
| **Competition** | Публичная таблица лидеров по реализованной доходности, каждая запись привязана к своей модели |
|
||||
| **Dashboard** | Живые позиции, ордера, статистика и обоснование каждого решения |
|
||||
|
||||
<details>
|
||||
<summary><b>Страница настроек</b></summary>
|
||||
<summary>Скриншоты</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>
|
||||
<br/>
|
||||
|
||||
| Обзор | График рынка |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-page.png" width="400"/> | <img src="../../../screenshots/dashboard-market-chart.png" width="400"/> |
|
||||
|
||||
| Статистика торгов | История позиций |
|
||||
| Статистика торговли | История позиций |
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/dashboard-trading-stats.png" width="400"/> | <img src="../../../screenshots/dashboard-position-history.png" width="400"/> |
|
||||
|
||||
| Позиции | Детали трейдера |
|
||||
| :----------------------------------------------------------: | :---------------------------------------------------: |
|
||||
| <img src="../../../screenshots/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"/> |
|
||||
| Соревнование | Конфигурация |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Установка
|
||||
## Модели
|
||||
|
||||
### Linux / macOS
|
||||
Восемь провайдеров с вашими собственными ключами — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — включая пользовательские эндпоинты и имена моделей.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
Или вовсе без ключей: [Claw402](https://claw402.ai) тарифицирует использование моделей за каждый вызов в USDC по протоколу x402. Кошелёк в сети Base заменяет все API-ключи.
|
||||
|
||||
### Railway (облако)
|
||||
| Провайдер | Доступ |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [AI-модели с оплатой по мере использования и официальной скидкой](https://claw402.ai) |
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
## Рынки
|
||||
|
||||
### Docker
|
||||
Криптовалютные бессрочные контракты на всех девяти биржах. На Hyperliquid тот же рантайм торгует также токенизированными акциями США, сырьевыми товарами, индексами, валютными парами и pre-IPO перпетуалами — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — наряду с криптовалютой.
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Установите [Docker Desktop](https://www.docker.com/products/docker-desktop/), затем:
|
||||
|
||||
```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
|
||||
# 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
|
||||
```
|
||||
|
||||
### Обновление
|
||||
|
||||
```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`
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
@@ -261,50 +159,99 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Установка
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
**Windows** — установите [Docker Desktop](https://www.docker.com/products/docker-desktop/), затем:
|
||||
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
**Из исходников** — Go 1.21+, Node.js 18+:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
**Обновление** — повторно запустите скрипт установки; он обновит всё на месте.
|
||||
|
||||
<details>
|
||||
<summary>Развёртывание на сервере</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**HTTPS через Cloudflare**
|
||||
|
||||
1. Добавьте домен в [Cloudflare](https://dash.cloudflare.com) (бесплатный план)
|
||||
2. A-запись → IP сервера, с проксированием
|
||||
3. SSL/TLS → Flexible
|
||||
4. `TRANSPORT_ENCRYPTION=true` в `.env`
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## Документация
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| [Архитектура](../../architecture/README.md) | Дизайн системы и индекс модулей |
|
||||
| [Модуль стратегий](../../architecture/STRATEGY_MODULE.md) | Выбор инструментов, AI-промпты, исполнение |
|
||||
| [FAQ](../../faq/README.md) | Частые вопросы |
|
||||
| [Начало работы](../../getting-started/README.md) | Гайд по деплою |
|
||||
| | |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [Начало работы](../../getting-started/README.md) | Гайды по развёртыванию и биржевым API |
|
||||
| [Архитектура](../../architecture/README.md) | Устройство системы и индекс модулей |
|
||||
| [Модуль стратегий](../../architecture/STRATEGY_MODULE.md) | Выбор монет, AI-промпты, исполнение |
|
||||
| [FAQ](../../guides/faq.en.md) | Частые вопросы |
|
||||
| [Устранение неполадок](../../guides/TROUBLESHOOTING.md) | Диагностика типичных проблем |
|
||||
|
||||
---
|
||||
## Сообщество
|
||||
|
||||
## Участие
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [Живой дашборд](https://vergex.trade/explore)
|
||||
|
||||
См. [Contributing Guide](../../../CONTRIBUTING.md), [Code of Conduct](../../../CODE_OF_CONDUCT.md) и [Security Policy](../../../SECURITY.md).
|
||||
## Участие в разработке
|
||||
|
||||
### Программа для контрибьюторов
|
||||
Код, документация, переводы и сообщения об ошибках — всё это приветствуется; см. [руководство для контрибьюторов](../../../CONTRIBUTING.md), [кодекс поведения](../../../CODE_OF_CONDUCT.md) и [политику безопасности](../../../SECURITY.md).
|
||||
|
||||
NOFX отслеживает значимые вклады и планирует вознаграждать контрибьюторов по мере роста экосистемы. Приоритетные issues имеют более высокий вес награды.
|
||||
NOFX отслеживает значимые вклады и намерен вознаграждать контрибьюторов по мере роста экосистемы. Приоритетные issues имеют больший вес.
|
||||
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
| Вклад | Вес |
|
||||
| :---------------- | :----: |
|
||||
| PR по закреплённым issues | ★★★★★★ |
|
||||
| Код (принятые PR) | ★★★★★ |
|
||||
| Исправления ошибок | ★★★★ |
|
||||
| Идеи новых функций | ★★★ |
|
||||
| Сообщения об ошибках | ★★ |
|
||||
| Документация | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## Ссылки
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| 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) |
|
||||
|
||||
> **Предупреждение о рисках**: автоматическая торговля связана со значительным риском. Контролируйте размер позиции, понимайте устройство каждой площадки и не торгуйте средствами, потерю которых не можете себе позволить.
|
||||
|
||||
---
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## Спонсоры
|
||||
|
||||
@@ -322,8 +269,14 @@ NOFX отслеживает значимые вклады и планирует
|
||||
|
||||
[Стать спонсором](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
## License
|
||||
<br/>
|
||||
|
||||
Если NOFX вам полезен, звезда помогает другим трейдерам его найти.
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## Лицензия
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>Автоматическая торговля сопряжена со значительным риском. Стратегии на основе AI экспериментальны и могут терять деньги. Разумно выбирайте размер позиций, разбирайтесь в устройстве каждой площадки и никогда не торгуйте средствами, потерю которых не можете себе позволить. Полный [дисклеймер](../../../DISCLAIMER.md).</sub>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<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>Дослідження, генерація стратегій, виконання та моніторинг для акцій США, сировинних товарів, FX і криптоактивів.</strong>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,231 +21,137 @@
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
NOFX — open-source AI-термінал для активних трейдерів, яким потрібен єдиний робочий простір для аналізу ринків, розробки стратегій, виконання угод і моніторингу портфеля.
|
||||
NOFX — торговий термінал з відкритим кодом, у якому стратегією є мовна модель. Кожен трейдер працює в безперервному циклі — читає структуру ринку, приймає рішення, виконує його, фіксує хід міркувань — а рантайм на Go обмежує кожен ордер жорсткими ризик-лімітами, які модель не може обійти.
|
||||
|
||||
Продукт орієнтований на ліквідні глобальні ринки: акції США, товарні контракти, валютні пари й цифрові активи. 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`.
|
||||
|
||||
---
|
||||
**Перший запуск**
|
||||
|
||||
1. Зареєструйтеся — перший акаунт стає власником інстансу.
|
||||
2. Пройдіть покроковий запуск: покладіть **$1+ USDC** (мережа Base) у гаманець для оплати AI, який він створить для вас, потім підключіть Hyperliquid і внесіть **$12+ USDC** для торгівлі.
|
||||
3. Запустіть **Autopilot**. AI сканує ринок кожні кілька хвилин і торгує самостійно; кожне рішення з'являється на дашборді в момент ухвалення. Зупинити його можна будь-коли одним кліком.
|
||||
|
||||
<br/>
|
||||
|
||||
## Реєстрація на біржах
|
||||
|
||||
Скористайтеся посиланнями нижче, щоб відкрити торгові акаунти для крипторинків і підтримуваних деривативів на акції США, FX та сировинні товари. Ці маршрути належать до партнерських програм NOFX і можуть містити знижки на комісії або реферальні переваги.
|
||||
NOFX безкоштовний і має відкритий код. Відкриття акаунта за партнерськими посиланнями нижче дає знижку на торгові комісії та фінансує подальшу розробку.
|
||||
|
||||
| Біржа | Статус | Реєстрація зі знижкою |
|
||||
| :--- | :---: | :--- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Реєстрація](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Реєстрація](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Реєстрація](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Реєстрація](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Реєстрація](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Реєстрація](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Реєстрація](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Реєстрація](https://app.lighter.xyz/?referral=68151432) |
|
||||
| Біржа | Статус | Реєстрація зі знижкою на комісії |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Реєстрація](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Реєстрація](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Реєстрація](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Реєстрація](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Реєстрація](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Реєстрація](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Реєстрація](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Реєстрація](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Реєстрація](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Коротка демонстрація
|
||||
## Демо
|
||||
|
||||
<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>
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
<p align="center">
|
||||
Натисніть на обкладинку, щоб переглянути демо.
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
---
|
||||
## Модель пропонує. Рантайм вирішує.
|
||||
|
||||
## Ринки
|
||||
Рішення приймає мовна модель, що читає стек даних [Claw402.ai](https://claw402.ai) · Vergex: живу сигнальну панель, яка ранжує кожен ринок за напрямком і силою сигналу, глибокі сигнали Signal Lab по кожному інструменту, теплові карти собівартості та ліквідацій, що показують, де в натовпу «паливо» і «стіни», та чистий потік коштів у реальному часі — усе це звіряється із сирими свічками та власним живим трек-рекордом трейдера. Виконання — ні.
|
||||
|
||||
**Акції США · Сировинні товари · FX · Криптоактиви**
|
||||
Кожен ордер проходить через ліміти, зашиті в код поза досяжністю моделі:
|
||||
|
||||
NOFX организует исследования, построение стратегий, исполнение и мониторинг вокруг мультиактивных рабочих процессов, а не вокруг одного торгового экрана.
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| Ліміти позицій | Максимум одночасних позицій, номінал обмежено часткою від капіталу, одна позиція на символ |
|
||||
| Обмеження плеча | Жорсткі стелі застосовуються під час розрахунку розміру ордера, незалежно від запиту моделі |
|
||||
| Захист на боці біржі | Стоп-лос і тейк-профіт розміщуються на біржі одразу після кожного входу |
|
||||
| Автозакриття за просадкою | Прибуткові позиції, що віддають надто багато від свого піку, закриваються |
|
||||
| Обмеження частоти угод | Мінімальний час утримання, кулдауни на повторний вхід за символом, ліміти входів на цикл і на годину |
|
||||
| Безпечний режим | Повторювані збої моделі блокують нові входи, доки модель не відновиться |
|
||||
| Передстартова перевірка | Доступ до моделі, кошти в гаманці, стратегія та баланси бірж перевіряються, перш ніж трейдер зможе стартувати |
|
||||
|
||||
---
|
||||
Кожне рішення зберігається разом із повним ходом міркувань моделі. Жодної позиції без документального сліду.
|
||||
|
||||
## Доступ до AI-моделей
|
||||
<br/>
|
||||
|
||||
NOFX автоматично маршрутизує AI inference через [Claw402](https://claw402.ai). Користувачам не потрібно налаштовувати провайдерів моделей, керувати API-ключами або підтримувати окремі AI-акаунти. Термінал звертається до підтримуваних моделей на вимогу через pay-as-you-go інфраструктуру Claw402 та офіційний канал зі знижкою.
|
||||
## Термінал
|
||||
|
||||
| Провайдер | Доступ |
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| **Claw402** | [Доступ до AI-моделей pay-as-you-go з офіційною знижкою](https://claw402.ai) |
|
||||
|
||||
---
|
||||
|
||||
## Можливості
|
||||
|
||||
| Возможность | Описание |
|
||||
| :--- | :--- |
|
||||
| **AI trading terminal** | Единое рабочее пространство для акций США, сырья, FX и криптоактивов |
|
||||
| **Доступ к AI-моделям** | Автоматический доступ к поддерживаемым провайдерам через Claw402 |
|
||||
| **Подключение бирж** | Binance, Bybit, OKX, Hyperliquid, Bitget, KuCoin, Gate, Aster, Lighter |
|
||||
| **Strategy Studio** | Рыночные универсумы, индикаторы, риск-контроль и логика стратегий |
|
||||
| **Соревнование моделей** | Сравнение AI-трейдеров по live-результатам и таблице лидеров |
|
||||
| **Telegram Agent** | Управление и мониторинг ассистента через чат |
|
||||
| **Портфельный дашборд** | Позиции, P/L, история исполнения и логи решений модели |
|
||||
|
||||
---
|
||||
|
||||
## Скріншоти
|
||||
| **Autopilot** | Покроковий запуск: поповнення, підключення, депозит, старт — із серверною передстартовою перевіркою на кожному кроці |
|
||||
| **Strategy Studio** | Пресети стилів, набори монет, індикатори, плече, впевненість входу, власні промпти |
|
||||
| **Змагання** | Публічна таблиця лідерів за реалізованою дохідністю; кожен запис прив'язаний до своєї моделі |
|
||||
| **Дашборд** | Живі позиції, ордери, статистика та обґрунтування кожного рішення |
|
||||
|
||||
<details>
|
||||
<summary><b>Сторінка налаштувань</b></summary>
|
||||
<summary>Скріншоти</summary>
|
||||
|
||||
| Конфігурація | Список трейдерів |
|
||||
| :----------------------------------------------------------: | :----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> | <img src="../../../screenshots/config-traders-list.png" width="400"/> |
|
||||
<br/>
|
||||
|
||||
</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"/> |
|
||||
| Змагання | Конфігурація |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Установка
|
||||
## Моделі
|
||||
|
||||
### Linux / macOS
|
||||
Вісім провайдерів із вашими власними ключами — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — включно з власними ендпоінтами та назвами моделей.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
Або взагалі без ключів: [Claw402](https://claw402.ai) тарифікує використання моделей за кожен виклик у USDC через протокол x402. Гаманець у мережі Base замінює всі API-ключі.
|
||||
|
||||
### Railway (облако)
|
||||
| Провайдер | Доступ |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [AI-моделі з оплатою за використання та офіційною знижкою](https://claw402.ai) |
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
## Ринки
|
||||
|
||||
### Docker
|
||||
Криптовалютні безстрокові контракти на всіх дев'яти біржах. На Hyperliquid той самий рантайм також торгує токенізованими акціями США, сировинними товарами, індексами, валютами та pre-IPO перпетуалами — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — поряд із криптою.
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Установите [Docker Desktop](https://www.docker.com/products/docker-desktop/), затем:
|
||||
|
||||
```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
|
||||
# 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
|
||||
```
|
||||
|
||||
### Обновление
|
||||
|
||||
```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`
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Архітектура
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
@@ -261,50 +159,99 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Документация
|
||||
## Встановлення
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| [Архитектура](../../architecture/README.md) | Дизайн системы и индекс модулей |
|
||||
| [Модуль стратегий](../../architecture/STRATEGY_MODULE.md) | Выбор инструментов, AI-промпты, исполнение |
|
||||
| [FAQ](../../faq/README.md) | Частые вопросы |
|
||||
| [Начало работы](../../getting-started/README.md) | Гайд по деплою |
|
||||
**Linux / macOS**
|
||||
|
||||
---
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
## Участие
|
||||
**Railway**
|
||||
|
||||
См. [Contributing Guide](../../../CONTRIBUTING.md), [Code of Conduct](../../../CODE_OF_CONDUCT.md) и [Security Policy](../../../SECURITY.md).
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
### Программа для контрибьюторов
|
||||
**Docker**
|
||||
|
||||
NOFX отслеживает значимые вклады и планирует вознаграждать контрибьюторов по мере роста экосистемы. Приоритетные issues имеют более высокий вес награды.
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
**Windows** — встановіть [Docker Desktop](https://www.docker.com/products/docker-desktop/), потім:
|
||||
|
||||
---
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## Посилання
|
||||
**З вихідного коду** — Go 1.21+, Node.js 18+:
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| 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) |
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
> **Попередження про ризики**: автоматизована торгівля має значний ризик. Контролюйте розмір позиції, розумійте механіку кожного майданчика і не торгуйте коштами, втрату яких не можете собі дозволити.
|
||||
**Оновлення** — запустіть інсталяційний скрипт ще раз; він оновить усе на місці.
|
||||
|
||||
---
|
||||
<details>
|
||||
<summary>Розгортання на сервері</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**HTTPS через Cloudflare**
|
||||
|
||||
1. Додайте домен у [Cloudflare](https://dash.cloudflare.com) (безкоштовний план)
|
||||
2. A-запис → IP сервера, проксійований
|
||||
3. SSL/TLS → Flexible
|
||||
4. `TRANSPORT_ENCRYPTION=true` у `.env`
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## Документація
|
||||
|
||||
| | |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [Початок роботи](../../getting-started/README.md) | Посібники з розгортання та біржових API |
|
||||
| [Архітектура](../../architecture/README.md) | Дизайн системи та індекс модулів |
|
||||
| [Модуль стратегій](../../architecture/STRATEGY_MODULE.md) | Вибір монет, AI-промпти, виконання |
|
||||
| [FAQ](../../guides/faq.en.md) | Поширені запитання |
|
||||
| [Усунення несправностей](../../guides/TROUBLESHOOTING.md) | Діагностика типових проблем |
|
||||
|
||||
## Спільнота
|
||||
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [Живий дашборд](https://vergex.trade/explore)
|
||||
|
||||
## Участь у розробці
|
||||
|
||||
Код, документація, переклади та звіти про помилки — усе це вітається; див. [Посібник для контриб'юторів](../../../CONTRIBUTING.md), [Кодекс поведінки](../../../CODE_OF_CONDUCT.md) і [Політику безпеки](../../../SECURITY.md).
|
||||
|
||||
NOFX відстежує значущі внески й має намір винагороджувати контриб'юторів у міру зростання екосистеми. Пріоритетні issues мають більшу вагу.
|
||||
|
||||
| Внесок | Вага |
|
||||
| :---------------- | :----: |
|
||||
| PR до закріплених issues | ★★★★★★ |
|
||||
| Код (змерджені PR) | ★★★★★ |
|
||||
| Виправлення багів | ★★★★ |
|
||||
| Ідеї функцій | ★★★ |
|
||||
| Звіти про баги | ★★ |
|
||||
| Документація | ★★ |
|
||||
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## Спонсори
|
||||
|
||||
@@ -322,8 +269,14 @@ NOFX отслеживает значимые вклады и планирует
|
||||
|
||||
[Стати спонсором](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
## License
|
||||
<br/>
|
||||
|
||||
Якщо NOFX вам корисний, зірка допоможе іншим трейдерам його знайти.
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## Ліцензія
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>Автоматизована торгівля пов'язана зі значним ризиком. Стратегії на основі AI експериментальні й можуть втрачати гроші. Обирайте розмір позицій розважливо, розумійте кожен майданчик і ніколи не торгуйте коштами, втрату яких не можете собі дозволити. Повний [дисклеймер](../../../DISCLAIMER.md).</sub>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<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>Được hậu thuẫn bởi <a href="https://vergex.trade">vergex.trade</a></strong></p>
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,97 +21,83 @@
|
||||
<a href="README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
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.
|
||||
NOFX là một terminal giao dịch mã nguồn mở, nơi chiến lược chính là một mô hình ngôn ngữ. Mỗi trader chạy một vòng lặp liên tục — đọc cấu trúc thị trường, ra quyết định, thực thi, ghi lại lập luận — trong khi một runtime viết bằng Go ép mọi lệnh vào các giới hạn rủi ro cứng mà mô hình không thể vượt qua.
|
||||
|
||||
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.
|
||||
Trader được kết hợp tự do: bất kỳ mô hình nào, bất kỳ sàn nào trong chín sàn, bất kỳ chiến lược nào. Chạy nhiều trader song song và so sánh chúng trên bảng xếp hạng công khai theo lợi nhuận đã thực hiện. Mọi thứ chạy trên máy của chính bạn; thông tin xác thực sàn được mã hóa khi lưu trữ và không bao giờ rời khỏi máy.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
**http://127.0.0.1:3000** 을 엽니다.
|
||||
Terminal mở tại `http://127.0.0.1:3000`.
|
||||
|
||||
---
|
||||
**Lần chạy đầu tiên**
|
||||
|
||||
1. Đăng ký — tài khoản đầu tiên trở thành chủ sở hữu của instance.
|
||||
2. Làm theo quy trình khởi chạy có hướng dẫn: nạp **$1+ USDC** (mạng Base) vào ví phí AI được tạo sẵn cho bạn, sau đó kết nối Hyperliquid và nạp **$12+ USDC** để giao dịch.
|
||||
3. Khởi động **Autopilot**. AI quét thị trường vài phút một lần và tự mình giao dịch; mọi quyết định hiện lên dashboard ngay khi diễn ra. Dừng lại bất cứ lúc nào chỉ với một cú nhấp.
|
||||
|
||||
<br/>
|
||||
|
||||
## Đăng ký sàn giao dịch
|
||||
|
||||
Sử dụng các liên kết bên dưới để mở tài khoản giao dịch cho crypto và các thị trường phái sinh cổ phiếu Mỹ, FX, hàng hóa được hỗ trợ. Các tuyến này thuộc chương trình đối tác NOFX và có thể bao gồm ưu đãi phí hoặc quyền lợi giới thiệu.
|
||||
NOFX miễn phí và mã nguồn mở. Mở tài khoản qua các liên kết đối tác bên dưới sẽ được giảm phí giao dịch và góp phần tài trợ cho việc phát triển lâu dài.
|
||||
|
||||
| Sàn | Trạng thái | Đăng ký kèm ưu đãi phí |
|
||||
| :--- | :---: | :--- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Đăng ký](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Đăng ký](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Đăng ký](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Đăng ký](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Đăng ký](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Đăng ký](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Đăng ký](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [Đăng ký](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [Đăng ký](https://app.lighter.xyz/?referral=68151432) |
|
||||
| Sàn giao dịch | Trạng thái | Đăng ký kèm ưu đãi phí |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [Đăng ký](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [Đăng ký](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [Đăng ký](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [Đăng ký](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [Đăng ký](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [Đăng ký](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [Đăng ký](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <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) |
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Demo nhanh
|
||||
## Demo
|
||||
|
||||
<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>
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
<p align="center">
|
||||
Nhấp vào ảnh bìa để xem video demo.
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
---
|
||||
## Mô hình đề xuất. Runtime định đoạt.
|
||||
|
||||
## Thị trường
|
||||
Các quyết định đến từ một mô hình ngôn ngữ đọc bộ dữ liệu [Claw402.ai](https://claw402.ai) · Vergex: bảng tín hiệu trực tiếp xếp hạng mọi thị trường theo thiên hướng và độ mạnh tín hiệu, tín hiệu chuyên sâu Signal Lab cho từng mã, bản đồ nhiệt chi phí vốn và thanh lý cho thấy "nhiên liệu" và "bức tường" của đám đông nằm ở đâu, cùng dòng tiền ròng theo thời gian thực — tất cả được đối chiếu với nến thô và thành tích thực chiến của chính trader. Việc thực thi thì không.
|
||||
|
||||
**Cổ phiếu Mỹ · Hàng hóa · Ngoại hối · Crypto**
|
||||
Mọi lệnh đều phải đi qua các giới hạn được thực thi bằng code, nằm ngoài tầm với của mô hình:
|
||||
|
||||
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ẻ.
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| Giới hạn vị thế | Số vị thế đồng thời tối đa, giá trị danh nghĩa bị chặn theo tỷ lệ vốn, mỗi symbol chỉ một vị thế |
|
||||
| Kẹp đòn bẩy | Trần cứng áp dụng ngay khi tính khối lượng lệnh, bất kể mô hình yêu cầu gì |
|
||||
| Bảo vệ phía sàn | Stop-loss và take-profit được đặt trên sàn ngay sau mỗi lần vào lệnh |
|
||||
| Tự đóng khi rút vốn từ đỉnh | Vị thế đang lãi nhưng để mất quá nhiều so với đỉnh sẽ bị đóng |
|
||||
| Điều tiết giao dịch | Thời gian giữ tối thiểu, thời gian chờ vào lại theo từng symbol, giới hạn số lần vào lệnh theo chu kỳ và theo giờ |
|
||||
| Chế độ an toàn | Mô hình lỗi liên tiếp sẽ bị chặn vào lệnh mới cho đến khi mô hình hồi phục |
|
||||
| Kiểm tra tiền khởi chạy | Quyền truy cập mô hình, tiền trong ví, chiến lược và số dư sàn được xác minh trước khi trader được phép khởi động |
|
||||
|
||||
---
|
||||
Mỗi quyết định được lưu kèm toàn bộ lập luận của mô hình. Không có vị thế nào thiếu hồ sơ lưu vết.
|
||||
|
||||
## Truy cập mô hình AI
|
||||
<br/>
|
||||
|
||||
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.
|
||||
## Terminal
|
||||
|
||||
| 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
|
||||
| **Autopilot** | Khởi chạy có hướng dẫn: nạp phí, kết nối, nạp tiền, khởi động — với kiểm tra preflight phía máy chủ xuyên suốt |
|
||||
| **Strategy Studio** | Preset phong cách, tập coin, chỉ báo, đòn bẩy, độ tin cậy vào lệnh, prompt tùy chỉnh |
|
||||
| **Cạnh tranh** | Bảng xếp hạng công khai theo lợi nhuận đã thực hiện, mỗi mục được ghi nhận cho mô hình tạo ra nó |
|
||||
| **Dashboard** | Vị thế trực tiếp, lệnh, thống kê và lập luận đằng sau mỗi quyết định |
|
||||
|
||||
<details>
|
||||
<summary><b>Trang cấu hình</b></summary>
|
||||
<summary>Ảnh chụp màn hình</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>
|
||||
<br/>
|
||||
|
||||
| Tổng quan | Biểu đồ thị trường |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
@@ -129,131 +107,51 @@ NOFX tự động định tuyến AI inference qua [Claw402](https://claw402.ai)
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <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"/> |
|
||||
| Cạnh tranh | Cấu hình |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Cài đặt
|
||||
## Mô hình
|
||||
|
||||
### Linux / macOS
|
||||
Tám nhà cung cấp với key của riêng bạn — DeepSeek, OpenAI, Claude, Qwen, Gemini, Grok, Kimi, MiniMax — bao gồm cả endpoint và tên mô hình tùy chỉnh.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
Hoặc không cần key nào cả: [Claw402](https://claw402.ai) tính phí sử dụng mô hình theo từng lần gọi bằng USDC qua giao thức x402. Một chiếc ví trên Base thay thế mọi API key.
|
||||
|
||||
### Railway (Cloud)
|
||||
| Nhà cung cấp | Truy cập |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [Mô hình AI trả theo mức dùng với ưu đãi chính thức](https://claw402.ai) |
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
## Thị trường
|
||||
|
||||
### Docker
|
||||
Hợp đồng vĩnh cửu crypto trên cả chín sàn. Trên Hyperliquid, cùng một runtime còn giao dịch cổ phiếu Mỹ token hóa, hàng hóa, chỉ số, ngoại hối và hợp đồng vĩnh cửu pre-IPO — TSLA, NVDA, GOLD, SPX, EUR, OPENAI — song song với crypto.
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 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
|
||||
# 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
|
||||
```
|
||||
|
||||
### 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`
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Kiến trúc
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
@@ -261,50 +159,99 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## Cài đặt
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
**Windows** — 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
|
||||
```
|
||||
|
||||
**Từ mã nguồn** — Go 1.21+, Node.js 18+:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
**Cập nhật** — chạy lại script cài đặt; nó tự nâng cấp tại chỗ.
|
||||
|
||||
<details>
|
||||
<summary>Triển khai lên máy chủ</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**HTTPS qua Cloudflare**
|
||||
|
||||
1. Thêm domain vào [Cloudflare](https://dash.cloudflare.com) (gói miễn phí)
|
||||
2. Bản ghi A → IP máy chủ, bật proxy
|
||||
3. SSL/TLS → Flexible
|
||||
4. `TRANSPORT_ENCRYPTION=true` trong `.env`
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## Tài liệu
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| [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 |
|
||||
| | |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [Bắt đầu](../../getting-started/README.md) | Hướng dẫn triển khai và API sàn |
|
||||
| [Kiến trúc](../../architecture/README.md) | Thiết kế hệ thống và chỉ mục module |
|
||||
| [Module chiến lược](../../architecture/STRATEGY_MODULE.md) | Chọn coin, prompt AI, thực thi |
|
||||
| [FAQ](../../guides/faq.en.md) | Câu hỏi thường gặp |
|
||||
| [Khắc phục sự cố](../../guides/TROUBLESHOOTING.md) | Chẩn đoán các sự cố thường gặp |
|
||||
|
||||
---
|
||||
## Cộng đồng
|
||||
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [Dashboard trực tiếp](https://vergex.trade/explore)
|
||||
|
||||
## Đóng góp
|
||||
|
||||
Xem [Contributing Guide](../../../CONTRIBUTING.md), [Code of Conduct](../../../CODE_OF_CONDUCT.md), và [Security Policy](../../../SECURITY.md).
|
||||
Code, tài liệu, bản dịch và báo cáo lỗi đều được hoan nghênh — xem [Hướng dẫn đóng góp](../../../CONTRIBUTING.md), [Quy tắc ứng xử](../../../CODE_OF_CONDUCT.md) và [Chính sách bảo mật](../../../SECURITY.md).
|
||||
|
||||
### Chương trình contributor
|
||||
NOFX theo dõi các đóng góp có ý nghĩa và dự định thưởng cho người đóng góp khi hệ sinh thái phát triển. Issue ưu tiên có trọng số cao hơn.
|
||||
|
||||
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.
|
||||
| Đóng góp | Trọng số |
|
||||
| :---------------- | :----: |
|
||||
| PR cho issue được ghim | ★★★★★★ |
|
||||
| Code (PR đã merge) | ★★★★★ |
|
||||
| Sửa lỗi | ★★★★ |
|
||||
| Ý tưởng tính năng | ★★★ |
|
||||
| Báo cáo lỗi | ★★ |
|
||||
| Tài liệu | ★★ |
|
||||
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## Liên kết
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| 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) |
|
||||
|
||||
> **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.
|
||||
|
||||
---
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## Nhà tài trợ
|
||||
|
||||
@@ -322,8 +269,14 @@ NOFX ghi nhận các đóng góp có ý nghĩa và dự định thưởng cho co
|
||||
|
||||
[Trở thành nhà tài trợ](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
## License
|
||||
<br/>
|
||||
|
||||
Nếu NOFX hữu ích với bạn, một ngôi sao sẽ giúp các trader khác tìm thấy nó.
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## Giấy phép
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>Giao dịch tự động tiềm ẩn rủi ro đáng kể. Các chiến lược do AI điều khiển mang tính thử nghiệm và có thể thua lỗ. Hãy đặt kích thước vị thế hợp lý, hiểu rõ từng sàn giao dịch, và đừng bao giờ giao dịch bằng số tiền mà bạn không thể để mất. Xem đầy đủ [tuyên bố miễn trừ trách nhiệm](../../../DISCLAIMER.md).</sub>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<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>覆盖美股、大宗商品、外汇与加密市场的研究、策略生成、执行与监控。</strong>
|
||||
<img src="../../assets/nofx-banner.svg" alt="NOFX — AI trading terminal" width="100%"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=for-the-badge" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=for-the-badge" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg?style=for-the-badge" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/Telegram-Community-blue?style=for-the-badge&logo=telegram" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://golang.org/"><img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go" alt="Go"></a>
|
||||
<a href="https://reactjs.org/"><img src="https://img.shields.io/badge/React-18+-61DAFB?style=flat&logo=react" alt="React"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/stargazers"><img src="https://img.shields.io/github/stars/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Stars"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/releases"><img src="https://img.shields.io/github/v/release/NoFxAiOS/nofx?style=flat-square&labelColor=1A1813&color=E0483B" alt="Release"></a>
|
||||
<a href="https://github.com/NoFxAiOS/nofx/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-E0483B?style=flat-square&labelColor=1A1813" alt="License"></a>
|
||||
<a href="https://t.me/nofx_dev_community"><img src="https://img.shields.io/badge/telegram-community-E0483B?style=flat-square&labelColor=1A1813&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -29,99 +21,83 @@
|
||||
<a href="../vi/README.md">Tiếng Việt</a>
|
||||
</p>
|
||||
|
||||
> **语言声明:** 本中文版本文档仅为方便海外华人社区阅读而提供,不代表本软件面向中国大陆、香港、澳门或台湾地区用户开放。如您位于上述地区,请勿使用本软件。
|
||||
<br/>
|
||||
|
||||
---
|
||||
NOFX 是一个开源交易终端,策略本身就是一个语言模型。每个交易员运行一个持续循环——读取市场结构、做出决策、执行、记录推理过程——同时由 Go 运行时把每一笔订单钳制在模型无法越过的硬性风控限制之内。
|
||||
|
||||
NOFX 是一个开源 AI 交易终端,面向需要统一工作区完成市场研究、策略开发、交易执行与组合监控的活跃交易者。
|
||||
|
||||
产品围绕全球高流动性市场设计:美股、大宗商品合约、外汇货币对与数字资产。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` 打开。
|
||||
|
||||
---
|
||||
**首次运行**
|
||||
|
||||
1. 注册——第一个账户即成为该实例的所有者。
|
||||
2. 按引导式启动流程操作:向系统为你创建的 AI 费用钱包存入 **$1+ USDC**(Base 网络),然后连接 Hyperliquid 并存入 **$12+ USDC** 作为交易资金。
|
||||
3. 启动 **Autopilot**。AI 每隔几分钟扫描一次市场并自主交易;每一个决策都会实时出现在仪表板上。随时可以一键停止。
|
||||
|
||||
<br/>
|
||||
|
||||
## 注册交易所
|
||||
|
||||
通过以下链接开通交易账户,可交易加密资产以及平台支持的美股、外汇和大宗商品衍生品市场。这些链接来自 NOFX 合作伙伴计划,可能包含手续费折扣或推荐权益。
|
||||
NOFX 免费且开源。通过下方合作伙伴链接开户可享受更低的交易手续费,同时也为项目的持续开发提供支持。
|
||||
|
||||
| 交易所 | 状态 | 享手续费折扣注册 |
|
||||
| :--- | :---: | :--- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [注册](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [注册](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [注册](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [注册](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [注册](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [注册](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [注册](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [注册](https://app.lighter.xyz/?referral=68151432) |
|
||||
| 交易所 | 状态 | 享手续费折扣注册 |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------- | :----: | :---------------------------------------------------------------------------------- |
|
||||
| <img src="../../../web/public/exchange-icons/binance.jpg" width="20" height="20" style="vertical-align: middle;"/> **Binance** | ✅ | [注册](https://www.binance.com/join?ref=NOFXENG) |
|
||||
| <img src="../../../web/public/exchange-icons/bybit.png" width="20" height="20" style="vertical-align: middle;"/> **Bybit** | ✅ | [注册](https://partner.bybit.com/b/83856) |
|
||||
| <img src="../../../web/public/exchange-icons/okx.svg" width="20" height="20" style="vertical-align: middle;"/> **OKX** | ✅ | [注册](https://www.okx.com/join/1865360) |
|
||||
| <img src="../../../web/public/exchange-icons/hyperliquid.png" width="20" height="20" style="vertical-align: middle;"/> **Hyperliquid** | ✅ | [注册](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| <img src="../../../web/public/exchange-icons/bitget.svg" width="20" height="20" style="vertical-align: middle;"/> **Bitget** | ✅ | [注册](https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172) |
|
||||
| <img src="../../../web/public/exchange-icons/kucoin.svg" width="20" height="20" style="vertical-align: middle;"/> **KuCoin** | ✅ | [注册](https://www.kucoin.com/r/broker/CXEV7XKK) |
|
||||
| <img src="../../../web/public/exchange-icons/gate.svg" width="20" height="20" style="vertical-align: middle;"/> **Gate** | ✅ | [注册](https://www.gatenode.xyz/share/VQBGUAxY) |
|
||||
| <img src="../../../web/public/exchange-icons/aster.svg" width="20" height="20" style="vertical-align: middle;"/> **Aster** | ✅ | [注册](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| <img src="../../../web/public/exchange-icons/lighter.png" width="20" height="20" style="vertical-align: middle;"/> **Lighter** | ✅ | [注册](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## 快速演示
|
||||
## 演示
|
||||
|
||||
<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>
|
||||
https://github.com/user-attachments/assets/3310f495-14c5-4586-a1cc-3d32e44aa505
|
||||
|
||||
<p align="center">
|
||||
点击封面图观看演示视频。
|
||||
</p>
|
||||
<br/>
|
||||
|
||||
---
|
||||
## 模型提议,运行时裁决
|
||||
|
||||
## 市场
|
||||
决策来自一个读取 [Claw402.ai](https://claw402.ai) · Vergex 数据栈的语言模型:实时信号看板为每个市场标注方向偏置与信号强度排名,Signal Lab 提供逐标的深度信号,成本与清算热力图揭示市场的"燃料"与"墙"在哪里,再叠加实时资金净流——并与原始 K 线和交易员自身的实盘战绩交叉验证。但执行不由它说了算。
|
||||
|
||||
**美股 · 大宗商品 · 外汇 · 加密资产**
|
||||
每一笔订单都要经过代码层面强制执行的限制,模型无从干预:
|
||||
|
||||
NOFX 按多资产工作流组织研究、策略构建、执行与监控,而不是停留在单一交易所界面。
|
||||
| | |
|
||||
| :----------------------- | :--------------------------------------------------------------------------------- |
|
||||
| 持仓限制 | 最大并发持仓数、名义价值按权益比例封顶、每个币种仅允许一个持仓 |
|
||||
| 杠杆钳制 | 在订单定量时施加硬上限,与模型请求的杠杆无关 |
|
||||
| 交易所端保护 | 每次开仓后立即在交易所挂出止损和止盈单 |
|
||||
| 回撤自动平仓 | 盈利持仓从峰值回吐过多时会被自动平掉 |
|
||||
| 交易节流 | 最短持仓时间、单币种再入场冷却、单周期与单小时开仓次数限制 |
|
||||
| 安全模式 | 模型连续失败时阻止新开仓,直至模型恢复正常 |
|
||||
| 启动预检 | 交易员启动前须通过模型访问、钱包资金、策略和交易所余额的校验 |
|
||||
|
||||
---
|
||||
每个决策都连同模型的完整推理一起存档。没有任何持仓是无据可查的。
|
||||
|
||||
## AI 模型接入
|
||||
<br/>
|
||||
|
||||
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** | 通过聊天控制和监控交易助手 |
|
||||
| **组合仪表板** | 持仓、盈亏、执行历史与模型决策日志 |
|
||||
|
||||
---
|
||||
|
||||
## 截图
|
||||
| **Autopilot** | 引导式启动:注资、连接、入金、启动——全程由服务端预检保驾护航 |
|
||||
| **Strategy Studio** | 风格预设、币种池、技术指标、杠杆、开仓置信度、自定义提示词 |
|
||||
| **竞赛** | 按已实现收益排名的公开排行榜,每个条目都标注所用模型 |
|
||||
| **仪表板** | 实时持仓、订单、统计数据,以及每个决策背后的推理 |
|
||||
|
||||
<details>
|
||||
<summary><b>配置页</b></summary>
|
||||
<summary>截图</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>
|
||||
<br/>
|
||||
|
||||
| 概览 | 行情图表 |
|
||||
| :-----------------------------------------------------: | :-------------------------------------------------------------: |
|
||||
@@ -131,131 +107,51 @@ NOFX 自动通过 [Claw402](https://claw402.ai) 路由 AI 推理请求。用户
|
||||
| :--------------------------------------------------------------: | :-----------------------------------------------------------------: |
|
||||
| <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"/> |
|
||||
| 竞赛 | 配置 |
|
||||
| :-------------------------------------------------------: | :-----------------------------------------------------------: |
|
||||
| <img src="../../../screenshots/competition-page.png" width="400"/> | <img src="../../../screenshots/config-ai-exchanges.png" width="400"/> |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## 安装
|
||||
## 模型
|
||||
|
||||
### Linux / macOS
|
||||
八家提供商,使用你自己的密钥——DeepSeek、OpenAI、Claude、Qwen、Gemini、Grok、Kimi、MiniMax——并支持自定义端点和模型名称。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
或者完全不需要密钥:[Claw402](https://claw402.ai) 通过 x402 协议以 USDC 按次计量模型用量。一个 Base 链上的钱包即可替代所有 API 密钥。
|
||||
|
||||
### Railway(云部署)
|
||||
| 提供商 | 接入方式 |
|
||||
| :------- | :----- |
|
||||
| **Claw402** | [按量付费的 AI 模型,享官方折扣](https://claw402.ai) |
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
## 市场
|
||||
|
||||
### Docker
|
||||
九家交易所全部支持加密货币永续合约。在 Hyperliquid 上,同一套运行时还可以交易代币化美股、大宗商品、指数、外汇和 pre-IPO 永续合约——TSLA、NVDA、GOLD、SPX、EUR、OPENAI——与加密资产并行。
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
安装 [Docker Desktop](https://www.docker.com/products/docker-desktop/),然后:
|
||||
|
||||
```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
|
||||
# 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
|
||||
```
|
||||
|
||||
### 更新
|
||||
|
||||
```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(开启代理)
|
||||
3. SSL/TLS 选择 Flexible
|
||||
4. 在 `.env` 中设置 `TRANSPORT_ENCRYPTION=true`
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
NOFX
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Trading Terminal │
|
||||
│ React + TypeScript + TradingView │
|
||||
│ US Stocks · Commodities · Forex · Crypto │
|
||||
│ React · TypeScript · TradingView │
|
||||
│ Dashboard · Strategy Studio · Competition │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API Server (Go) │
|
||||
│ JWT auth · encrypted credential store │
|
||||
├──────────────┬──────────────┬───────────────────┤
|
||||
│ Strategy │ Telegram │ Trader Runtime │
|
||||
│ Engine │ Agent │ Risk Controls │
|
||||
│ Strategy │ Autopilot │ Trader Runtime │
|
||||
│ Engine │ Preflight │ Risk Engine │
|
||||
├──────────────┴──────────────┴───────────────────┤
|
||||
│ AI Model Layer │
|
||||
│ Unified provider access through Claw402 │
|
||||
│ Model routing · payment · execution support │
|
||||
│ DeepSeek · OpenAI · Claude · Qwen · Gemini │
|
||||
│ Grok · Kimi · MiniMax · Claw402 (x402 USDC) │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ Exchange Connectivity │
|
||||
│ Binance · Bybit · OKX · Hyperliquid · Bitget │
|
||||
@@ -263,50 +159,99 @@ curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bas
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
<br/>
|
||||
|
||||
## 安装
|
||||
|
||||
**Linux / macOS**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
```
|
||||
|
||||
**Railway**
|
||||
|
||||
[](https://railway.com/deploy/nofx?referralCode=nofx)
|
||||
|
||||
**Docker**
|
||||
|
||||
```bash
|
||||
curl -O https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
**Windows** —— 安装 [Docker Desktop](https://www.docker.com/products/docker-desktop/),然后:
|
||||
|
||||
```powershell
|
||||
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/NoFxAiOS/nofx/main/docker-compose.prod.yml
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
**从源码构建** —— 需要 Go 1.21+、Node.js 18+:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NoFxAiOS/nofx.git && cd nofx
|
||||
go build -o nofx && ./nofx # backend
|
||||
cd web && npm install && npm run dev # frontend, in a second terminal
|
||||
```
|
||||
|
||||
**更新** —— 重新运行安装脚本,即可原地升级。
|
||||
|
||||
<details>
|
||||
<summary>服务器部署</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
**HTTP**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash
|
||||
# http://YOUR_IP:3000
|
||||
```
|
||||
|
||||
**通过 Cloudflare 启用 HTTPS**
|
||||
|
||||
1. 将域名添加到 [Cloudflare](https://dash.cloudflare.com)(免费套餐即可)
|
||||
2. A 记录 → 服务器 IP,开启代理
|
||||
3. SSL/TLS → Flexible
|
||||
4. 在 `.env` 中设置 `TRANSPORT_ENCRYPTION=true`
|
||||
|
||||
</details>
|
||||
|
||||
<br/>
|
||||
|
||||
## 文档
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| [架构概览](../../architecture/README.md) | 系统设计和模块索引 |
|
||||
| | |
|
||||
| :------------------------------------------------------ | :------------------------------------ |
|
||||
| [快速开始](../../getting-started/README.zh-CN.md) | 部署与交易所 API 指南 |
|
||||
| [架构](../../architecture/README.md) | 系统设计与模块索引 |
|
||||
| [策略模块](../../architecture/STRATEGY_MODULE.md) | 币种选择、AI 提示词、执行 |
|
||||
| [常见问题](../../faq/README.md) | FAQ |
|
||||
| [快速开始](../../getting-started/README.md) | 部署指南 |
|
||||
| [常见问题](../../guides/faq.zh-CN.md) | 常见疑问解答 |
|
||||
| [故障排查](../../guides/TROUBLESHOOTING.zh-CN.md) | 常见问题诊断 |
|
||||
|
||||
---
|
||||
## 社区
|
||||
|
||||
[Telegram](https://t.me/nofx_dev_community) · [Twitter/X](https://x.com/vergex_ai) · [Issues](https://github.com/NoFxAiOS/nofx/issues) · [vergex.trade](https://vergex.trade) · [实时仪表板](https://vergex.trade/explore)
|
||||
|
||||
## 贡献
|
||||
|
||||
查看 [贡献指南](../../../CONTRIBUTING.md)、[行为准则](../../../CODE_OF_CONDUCT.md) 与 [安全政策](../../../SECURITY.md)。
|
||||
代码、文档、翻译和 Bug 报告都欢迎——参见[贡献指南](../../../CONTRIBUTING.md)、[行为准则](../../../CODE_OF_CONDUCT.md)和[安全政策](../../../SECURITY.md)。
|
||||
|
||||
### 贡献者计划
|
||||
NOFX 会记录有价值的贡献,并计划随着生态发展回馈贡献者。优先级 Issue 拥有更高权重。
|
||||
|
||||
NOFX 会记录有价值的贡献,并计划在生态增长后回馈贡献者。优先级 Issue 拥有更高奖励权重。
|
||||
| 贡献类型 | 权重 |
|
||||
| :---------------- | :----: |
|
||||
| 置顶 Issue 的 PR | ★★★★★★ |
|
||||
| 代码(已合并 PR) | ★★★★★ |
|
||||
| Bug 修复 | ★★★★ |
|
||||
| 功能建议 | ★★★ |
|
||||
| Bug 报告 | ★★ |
|
||||
| 文档 | ★★ |
|
||||
|
||||
| Contribution | Weight |
|
||||
| :--- | :---: |
|
||||
| Pinned Issue PRs | ★★★★★★ |
|
||||
| Code (Merged PRs) | ★★★★★ |
|
||||
| Bug Fixes | ★★★★ |
|
||||
| Feature Ideas | ★★★ |
|
||||
| Bug Reports | ★★ |
|
||||
| Documentation | ★★ |
|
||||
|
||||
---
|
||||
|
||||
## 链接
|
||||
|
||||
| | |
|
||||
| :--- | :--- |
|
||||
| 官网 | [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) |
|
||||
|
||||
> **风险提示**:自动化交易存在重大风险。请控制仓位,理解每个交易场所的机制,不要投入无法承受损失的资金。
|
||||
|
||||
---
|
||||
<a href="https://github.com/NoFxAiOS/nofx/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=NoFxAiOS/nofx" alt="Contributors"/>
|
||||
</a>
|
||||
|
||||
## 赞助者
|
||||
|
||||
@@ -324,8 +269,14 @@ NOFX 会记录有价值的贡献,并计划在生态增长后回馈贡献者。
|
||||
|
||||
[成为赞助者](https://github.com/sponsors/NoFxAiOS)
|
||||
|
||||
## License
|
||||
<br/>
|
||||
|
||||
如果 NOFX 对你有帮助,点个 Star 能让更多交易者发现它。
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
|
||||
## 许可证
|
||||
|
||||
[AGPL-3.0](../../../LICENSE)
|
||||
|
||||
[](https://star-history.com/#NoFxAiOS/nofx&Date)
|
||||
<sub>自动化交易存在重大风险。AI 驱动的策略尚处实验阶段,可能造成亏损。请合理控制仓位规模,充分了解每个交易场所,切勿投入无法承受损失的资金。完整[免责声明](../../../DISCLAIMER.md)。</sub>
|
||||
|
||||
@@ -799,14 +799,51 @@ func (e *StrategyEngine) getVergexSignalCoins(limit int, marketType, chain, liqB
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
items := make([]vergex.SignalRankItem, 0, limit)
|
||||
// Direction-balanced selection: interleave the top bullish and top bearish
|
||||
// signals so the candidate universe carries BOTH long and short ideas every
|
||||
// cycle (instead of filling up with whichever bias ranks highest). This is
|
||||
// what lets the AI actually judge — and trade — both directions.
|
||||
var bullItems, bearItems, otherItems []vergex.SignalRankItem
|
||||
for _, item := range rankedItems {
|
||||
if category != "" && category != "all" && item.Category != category {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
if len(items) >= limit {
|
||||
break
|
||||
switch strings.ToLower(strings.TrimSpace(item.Bias)) {
|
||||
case "bearish", "short", "sell":
|
||||
bearItems = append(bearItems, item)
|
||||
case "bullish", "long", "buy":
|
||||
bullItems = append(bullItems, item)
|
||||
default:
|
||||
otherItems = append(otherItems, item)
|
||||
}
|
||||
}
|
||||
items := make([]vergex.SignalRankItem, 0, limit)
|
||||
bi, ri, oi := 0, 0, 0
|
||||
for len(items) < limit {
|
||||
progressed := false
|
||||
if bi < len(bullItems) {
|
||||
items = append(items, bullItems[bi])
|
||||
bi++
|
||||
progressed = true
|
||||
if len(items) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if ri < len(bearItems) {
|
||||
items = append(items, bearItems[ri])
|
||||
ri++
|
||||
progressed = true
|
||||
if len(items) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !progressed {
|
||||
if oi < len(otherItems) {
|
||||
items = append(items, otherItems[oi])
|
||||
oi++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
@@ -840,6 +877,56 @@ func minInt(a, b int) int {
|
||||
return b
|
||||
}
|
||||
|
||||
// DirectionalCandidate is a Vergex board candidate with its directional
|
||||
// signal strength (the board z-score; sign follows the bias direction).
|
||||
type DirectionalCandidate struct {
|
||||
Symbol string
|
||||
Score float64
|
||||
}
|
||||
|
||||
// DirectionalCandidates returns bullish (long) and bearish (short) candidates
|
||||
// from the most recent Vergex signal ranking, each ordered by upstream rank
|
||||
// (strongest first) and carrying the signal score so callers can require a
|
||||
// minimum strength. Only populated for vergex_signal coin sources, since that
|
||||
// is the only source carrying a per-symbol directional bias.
|
||||
func (e *StrategyEngine) DirectionalCandidates() (bullish []DirectionalCandidate, bearish []DirectionalCandidate) {
|
||||
if e == nil || len(e.vergexRankingCache) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
type ranked struct {
|
||||
cand DirectionalCandidate
|
||||
rank int
|
||||
}
|
||||
rankKey := func(r int) int {
|
||||
if r > 0 {
|
||||
return r
|
||||
}
|
||||
return 1 << 30
|
||||
}
|
||||
var bl, br []ranked
|
||||
for sym, item := range e.vergexRankingCache {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
entry := ranked{DirectionalCandidate{Symbol: sym, Score: item.Score}, item.Rank}
|
||||
switch strings.ToLower(strings.TrimSpace(item.Bias)) {
|
||||
case "bearish", "short", "sell":
|
||||
br = append(br, entry)
|
||||
case "bullish", "long", "buy":
|
||||
bl = append(bl, entry)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(bl, func(i, j int) bool { return rankKey(bl[i].rank) < rankKey(bl[j].rank) })
|
||||
sort.SliceStable(br, func(i, j int) bool { return rankKey(br[i].rank) < rankKey(br[j].rank) })
|
||||
for _, r := range bl {
|
||||
bullish = append(bullish, r.cand)
|
||||
}
|
||||
for _, r := range br {
|
||||
bearish = append(bearish, r.cand)
|
||||
}
|
||||
return bullish, bearish
|
||||
}
|
||||
|
||||
func withDefaultText(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
|
||||
42
kernel/engine_directional_test.go
Normal file
42
kernel/engine_directional_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"nofx/provider/vergex"
|
||||
)
|
||||
|
||||
func TestDirectionalCandidates(t *testing.T) {
|
||||
e := &StrategyEngine{
|
||||
vergexRankingCache: map[string]*vergex.SignalRankItem{
|
||||
"xyz:NVDA": {Symbol: "xyz:NVDA", Bias: "bullish", Rank: 2, Score: 1.07},
|
||||
"xyz:AAPL": {Symbol: "xyz:AAPL", Bias: "bullish", Rank: 1, Score: 1.76},
|
||||
"BTC": {Symbol: "BTC", Bias: "bearish", Rank: 3, Score: -0.9},
|
||||
"ETH": {Symbol: "ETH", Bias: "bearish", Rank: 1, Score: -0.05},
|
||||
"SOL": {Symbol: "SOL", Bias: "neutral", Rank: 1, Score: 0.4},
|
||||
},
|
||||
}
|
||||
|
||||
bull, bear := e.DirectionalCandidates()
|
||||
|
||||
if len(bull) != 2 || bull[0].Symbol != "xyz:AAPL" || bull[1].Symbol != "xyz:NVDA" {
|
||||
t.Fatalf("bullish should be rank-ordered [xyz:AAPL xyz:NVDA], got %v", bull)
|
||||
}
|
||||
if bull[0].Score != 1.76 || bull[1].Score != 1.07 {
|
||||
t.Fatalf("bullish candidates should carry their board scores, got %v", bull)
|
||||
}
|
||||
if len(bear) != 2 || bear[0].Symbol != "ETH" || bear[1].Symbol != "BTC" {
|
||||
t.Fatalf("bearish should be rank-ordered [ETH BTC], got %v", bear)
|
||||
}
|
||||
if bear[0].Score != -0.05 || bear[1].Score != -0.9 {
|
||||
t.Fatalf("bearish candidates should carry their board scores, got %v", bear)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectionalCandidatesEmpty(t *testing.T) {
|
||||
e := &StrategyEngine{}
|
||||
bull, bear := e.DirectionalCandidates()
|
||||
if len(bull) != 0 || len(bear) != 0 {
|
||||
t.Fatalf("empty cache should yield no candidates, got %v %v", bull, bear)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,14 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
|
||||
zh := false
|
||||
singleSymbol, primarySymbol := e.singleSymbolInfo()
|
||||
|
||||
// Configs created in the Chinese-UI era carry legacy stored prompt sections
|
||||
// and custom prompts written for a different contract; ignore them wholesale
|
||||
// and fall back to the canonical built-in English sections.
|
||||
legacyZhConfig := strings.EqualFold(strings.TrimSpace(e.config.Language), "zh")
|
||||
if legacyZhConfig {
|
||||
promptSections = store.PromptSectionsConfig{}
|
||||
}
|
||||
|
||||
if e.usesVergexSignalPrompt() {
|
||||
return e.buildVergexSystemPrompt(accountEquity, variant, lang, zh, singleSymbol, primarySymbol)
|
||||
}
|
||||
@@ -42,8 +50,8 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
|
||||
sb.WriteString(roleDefinition)
|
||||
sb.WriteString("\n\n")
|
||||
} else if zh {
|
||||
sb.WriteString("# 你是一名专业的 Hyperliquid USDC 多资产交易 AI\n\n")
|
||||
sb.WriteString("你的任务是基于提供的市场数据做出交易决策。\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")
|
||||
} else {
|
||||
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")
|
||||
@@ -75,11 +83,11 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
|
||||
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")
|
||||
sb.WriteString("# ⏱️ Trading Frequency Awareness\n\n")
|
||||
sb.WriteString("- Excellent traders: 2-4 trades/day ≈ 0.1-0.2 trades/hour\n")
|
||||
sb.WriteString("- >2 trades/hour = overtrading\n")
|
||||
sb.WriteString("- Single position hold time ≥ 45-90 minutes\n")
|
||||
sb.WriteString("If you find yourself trading every cycle → standards too low; if closing positions < 45 minutes → too impulsive.\n\n")
|
||||
} else {
|
||||
sb.WriteString("# ⏱️ Trading Frequency Awareness\n\n")
|
||||
sb.WriteString("- Excellent traders: 2-4 trades/day ≈ 0.1-0.2 trades/hour\n")
|
||||
@@ -93,21 +101,21 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
|
||||
if entryStandards != "" {
|
||||
sb.WriteString(entryStandards)
|
||||
if zh {
|
||||
sb.WriteString("\n\n你拥有以下指标数据:\n")
|
||||
sb.WriteString("\n\nYou have the following indicator data:\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))
|
||||
sb.WriteString(fmt.Sprintf("\n**Confidence ≥ %d** required to open positions.\n\n", riskControl.MinConfidence))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("\n**Confidence ≥ %d** required to open positions.\n\n", riskControl.MinConfidence))
|
||||
}
|
||||
} else if zh {
|
||||
sb.WriteString("# 🎯 入场标准 (严格)\n\n")
|
||||
sb.WriteString("只有当多重信号共振时才开仓。你拥有:\n")
|
||||
sb.WriteString("# 🎯 Entry Standards (Strict)\n\n")
|
||||
sb.WriteString("Only open positions when multiple signals resonate. You have:\n")
|
||||
e.writeAvailableIndicators(&sb, zh)
|
||||
sb.WriteString(fmt.Sprintf("\n请自由使用任何有效的分析方法, 但**置信度 ≥ %d** 才能开仓; 避免低质量行为, 如单一指标、信号矛盾、横盘震荡、平仓后立刻再开等。\n\n", riskControl.MinConfidence))
|
||||
sb.WriteString(fmt.Sprintf("\nFeel free to use any effective analysis method, but **confidence ≥ %d** is required to open positions; avoid low-quality behaviors such as single-indicator entries, contradictory signals, sideways chop, or re-entering immediately after a close.\n\n", riskControl.MinConfidence))
|
||||
} else {
|
||||
sb.WriteString("# 🎯 Entry Standards (Strict)\n\n")
|
||||
sb.WriteString("Only open positions when multiple signals resonate. You have:\n")
|
||||
@@ -121,10 +129,10 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
|
||||
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")
|
||||
sb.WriteString("# 📋 Decision Process\n\n")
|
||||
sb.WriteString("1. Check positions → take profit / stop loss?\n")
|
||||
sb.WriteString("2. Scan candidates + multi-timeframe → are there strong signals?\n")
|
||||
sb.WriteString("3. Write chain of thought first, then output structured JSON\n\n")
|
||||
} else {
|
||||
sb.WriteString("# 📋 Decision Process\n\n")
|
||||
sb.WriteString("1. Check positions → take profit / stop loss?\n")
|
||||
@@ -147,20 +155,23 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
|
||||
// 2. It guarantees a stock-specific, US-equity-tuned briefing
|
||||
// regardless of when the strategy was first created.
|
||||
customPrompt := englishOnlyPromptSection(e.config.CustomPrompt)
|
||||
if legacyZhConfig {
|
||||
customPrompt = ""
|
||||
}
|
||||
if singleSymbol && market.IsXyzDexAsset(primarySymbol) {
|
||||
customPrompt = buildXYZStockCustomPrompt(primarySymbol)
|
||||
}
|
||||
|
||||
if customPrompt != "" {
|
||||
if zh {
|
||||
sb.WriteString("# 📌 个性化交易策略\n\n")
|
||||
sb.WriteString("# 📌 Personalized Trading Strategy\n\n")
|
||||
} else {
|
||||
sb.WriteString("# 📌 Personalized Trading Strategy\n\n")
|
||||
}
|
||||
sb.WriteString(customPrompt)
|
||||
sb.WriteString("\n\n")
|
||||
if zh {
|
||||
sb.WriteString("说明: 上述个性化策略是基础规则的补充, 不能违反基础风控原则。\n")
|
||||
sb.WriteString("Note: the above personalized strategy supplements the basic rules and may not violate the core risk controls.\n")
|
||||
} else {
|
||||
sb.WriteString("Note: the above personalized strategy supplements the basic rules and may not violate the core risk controls.\n")
|
||||
}
|
||||
@@ -191,21 +202,22 @@ func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant
|
||||
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")
|
||||
sb.WriteString("# You are the NOFX Claw402 auto-trader\n\n")
|
||||
sb.WriteString("Trade only Hyperliquid instruments returned by this cycle's Claw402.ai/Vergex board. You may trade only the current candidate symbols and existing positions; never invent tickers or rotate outside the provided universe.\n\n")
|
||||
sb.WriteString("# Decision Data Priority\n\n")
|
||||
sb.WriteString("1. Claw402.ai Signal Ranking: candidate pool, rank, direction and category.\n")
|
||||
sb.WriteString("2. Claw402.ai Signal Lab: trend, momentum, event/model confirmation; this is the core pre-entry confirmation source.\n")
|
||||
sb.WriteString("3. Claw402.ai Cost/Liquidation Heatmap: crowded liquidation/cost zones, stop placement and target zones.\n")
|
||||
sb.WriteString("4. Raw OHLCV candles: entry timing, trend structure, volatility and risk/reward validation.\n\n")
|
||||
sb.WriteString("# Trading Rules\n\n")
|
||||
sb.WriteString("- Manage existing positions before opening new ones.\n")
|
||||
sb.WriteString("- Open only when Signal Lab, heatmap and raw candles broadly agree; wait when key data is missing or contradictory.\n")
|
||||
sb.WriteString("- Ranking alone is not an entry reason; it only defines the candidate pool.\n")
|
||||
sb.WriteString("- Every symbol in Candidate Coins is part of the allowed trading universe; missing detail can lower confidence or trigger waiting, but does not make the symbol non-tradable.\n")
|
||||
sb.WriteString("- If Signal Lab or heatmap is absent from that symbol's Vergex Claw402 Signals, state it in reasoning; if it is present, never claim the symbol lacks that data.\n")
|
||||
sb.WriteString("- Hold for BIG moves, do not churn: hold new positions for at least 4 hours; never close inside the -4%% to +6%% noise band before ~8 hours; after closing a symbol wait 3 hours before re-entry; open at most 1-2 new positions per hour. Small in-and-out trades bled this account to death on fees.\n")
|
||||
sb.WriteString("- Fees are the main edge killer: a round trip costs ~0.1%% of notional. Only enter setups where the realistic target is a LARGE move: stop-loss around -5%% and take-profit around +10-12%% (roughly 2:1 or better). Do not aim for 0.5%% scalps — they cannot cover fees. Fewer, high-conviction, wide-target, multi-hour holds only.\n")
|
||||
sb.WriteString("- Set WIDE stops and targets: place the stop well beyond short-term noise (around -5%%) and the target at a distant heatmap resistance/liquidation zone (around +10-12%%). Give the position room to develop; do not exit on small green or small red.\n\n")
|
||||
} else {
|
||||
sb.WriteString("# You are the NOFX Claw402 auto-trader\n\n")
|
||||
sb.WriteString("Trade only Hyperliquid instruments returned by this cycle's Claw402.ai/Vergex board. You may trade only the current candidate symbols and existing positions; never invent tickers or rotate outside the provided universe.\n\n")
|
||||
@@ -220,8 +232,9 @@ func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant
|
||||
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")
|
||||
sb.WriteString("- Hold for BIG moves, do not churn: hold new positions for at least 4 hours; never close inside the -4%% to +6%% noise band before ~8 hours; after closing a symbol wait 3 hours before re-entry; open at most 1-2 new positions per hour. Small in-and-out trades bled this account to death on fees.\n")
|
||||
sb.WriteString("- Fees are the main edge killer: a round trip costs ~0.1%% of notional. Only enter setups where the realistic target is a LARGE move: stop-loss around -5%% and take-profit around +10-12%% (roughly 2:1 or better). Do not aim for 0.5%% scalps — they cannot cover fees. Fewer, high-conviction, wide-target, multi-hour holds only.\n")
|
||||
sb.WriteString("- Set WIDE stops and targets: place the stop well beyond short-term noise (around -5%%) and the target at a distant heatmap resistance/liquidation zone (around +10-12%%). Give the position room to develop; do not exit on small green or small red.\n\n")
|
||||
}
|
||||
|
||||
writeModeVariant(&sb, variant, zh)
|
||||
@@ -233,7 +246,7 @@ func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant
|
||||
writeVergexHardConstraints(&sb, accountEquity, riskControl, altcoinPosValueRatio, zh)
|
||||
writeVergexOutputFormat(&sb, accountEquity, riskControl, altcoinPosValueRatio, singleSymbol, primarySymbol, zh)
|
||||
|
||||
customPrompt := englishOnlyPromptSection(e.config.CustomPrompt)
|
||||
customPrompt := vergexCustomPromptSection(e.config.CustomPrompt)
|
||||
if customPrompt != "" {
|
||||
sb.WriteString("# User Preference\n\n")
|
||||
sb.WriteString(customPrompt)
|
||||
@@ -243,6 +256,32 @@ func (e *StrategyEngine) buildVergexSystemPrompt(accountEquity float64, variant
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// vergexCustomPromptSection returns the user's custom prompt for the vergex
|
||||
// path, dropping legacy directional overrides ("long only" era) that would
|
||||
// contradict the data-driven direction rule baked into this prompt.
|
||||
func vergexCustomPromptSection(section string) string {
|
||||
trimmed := englishOnlyPromptSection(section)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
legacyDirectives := []string{
|
||||
"long only",
|
||||
"long-only",
|
||||
"do not short",
|
||||
"no shorts",
|
||||
"must open a long",
|
||||
"short only",
|
||||
"short-only",
|
||||
}
|
||||
for _, directive := range legacyDirectives {
|
||||
if strings.Contains(lower, directive) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func englishOnlyPromptSection(section string) string {
|
||||
trimmed := strings.TrimSpace(section)
|
||||
if trimmed == "" {
|
||||
@@ -256,15 +295,15 @@ func englishOnlyPromptSection(section string) string {
|
||||
|
||||
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")
|
||||
sb.WriteString("# Claw402.ai TradeFi Data Guide\n\n")
|
||||
sb.WriteString("- Equity: total account value including unrealized PnL, in USDT.\n")
|
||||
sb.WriteString("- Balance: available balance for new positions, in USDT.\n")
|
||||
sb.WriteString("- Margin: current margin usage; higher means more risk.\n")
|
||||
sb.WriteString("- Position: current holdings with side, entry, leverage, unrealized PnL and liquidation price.\n")
|
||||
sb.WriteString("- Claw402 Ranking: tradable candidate pool, rank, direction and category for this cycle.\n")
|
||||
sb.WriteString("- Signal Lab: per-symbol Claw402 deep signal used to confirm trend and quality.\n")
|
||||
sb.WriteString("- Cost/Liquidation Heatmap: cost and liquidation clusters used for stops, targets and crowding risk.\n")
|
||||
sb.WriteString("- Raw OHLCV Kline: raw candles used for trend structure, entry timing and risk/reward.\n")
|
||||
} else {
|
||||
sb.WriteString("# Claw402.ai TradeFi Data Guide\n\n")
|
||||
sb.WriteString("- Equity: total account value including unrealized PnL, in USDT.\n")
|
||||
@@ -281,22 +320,22 @@ func writeVergexSchemaPrompt(sb *strings.Builder, zh bool) {
|
||||
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")
|
||||
sb.WriteString("# Hard Risk Constraints\n\n")
|
||||
sb.WriteString("## Backend enforced\n")
|
||||
sb.WriteString(fmt.Sprintf("- Max positions: %d Claw402 candidate instruments at the same time\n", riskControl.MaxPositions))
|
||||
sb.WriteString(fmt.Sprintf("- Max notional per position: %.0f USDT (= equity %.0f × %.1fx)\n", maxPositionValue, accountEquity, tradeFiPositionValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Max margin usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min order size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
sb.WriteString("## AI guided\n")
|
||||
sb.WriteString(fmt.Sprintf("- Leverage: every open position must use exactly %dx\n", riskControl.AltcoinMaxLeverage))
|
||||
sb.WriteString(fmt.Sprintf("- Risk/reward: ≥1:%.1f\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min confidence to open: ≥%d\n\n", riskControl.MinConfidence))
|
||||
sb.WriteString("# Position Sizing\n\n")
|
||||
sb.WriteString("For every `open_long` or `open_short`, use the full max notional per position.\n")
|
||||
sb.WriteString("- Do not scale position_size_usd down by confidence.\n")
|
||||
sb.WriteString("- Do not open small probe positions.\n")
|
||||
sb.WriteString("- If the setup is not strong enough for full size, output `wait`.\n")
|
||||
sb.WriteString("- Do not use available_balance directly as position_size_usd.\n\n")
|
||||
} else {
|
||||
sb.WriteString("# Hard Risk Constraints\n\n")
|
||||
sb.WriteString("## Backend enforced\n")
|
||||
@@ -332,15 +371,21 @@ func writeVergexOutputFormat(sb *strings.Builder, accountEquity float64, riskCon
|
||||
|
||||
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")
|
||||
sb.WriteString("Use XML tags <reasoning> and <decision> to separate concise analysis from the decision JSON.\n\n")
|
||||
sb.WriteString("Direction must be data-driven: use `open_long` for confirmed upside structures and `open_short` for confirmed downside structures; never default to long-only or short-only behavior.\n\n")
|
||||
if !singleSymbol {
|
||||
sb.WriteString("Evaluate both directions every cycle, but enter a side only when its own signals independently justify it. Never open a position just to balance the book — an unbalanced book beats a forced trade.\n\n")
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("Use XML tags <reasoning> and <decision> to separate concise analysis from the decision JSON.\n\n")
|
||||
sb.WriteString("Direction must be data-driven: use `open_long` for confirmed upside structures and `open_short` for confirmed downside structures; never default to long-only or short-only behavior.\n\n")
|
||||
if !singleSymbol {
|
||||
sb.WriteString("Evaluate both directions every cycle, but enter a side only when its own signals independently justify it. Never open a position just to balance the book — an unbalanced book beats a forced trade.\n\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString("<reasoning>\n")
|
||||
if zh {
|
||||
sb.WriteString("简明说明: Claw402 排名、Signal Lab、热力图、K 线是否一致;如果缺数据或冲突,说明为什么等待。\n")
|
||||
sb.WriteString("Briefly state whether Claw402 ranking, Signal Lab, heatmap and candles agree; if data is missing or conflicting, explain why you wait.\n")
|
||||
} else {
|
||||
sb.WriteString("Briefly state whether Claw402 ranking, Signal Lab, heatmap and candles agree; if data is missing or conflicting, explain why you wait.\n")
|
||||
}
|
||||
@@ -357,15 +402,15 @@ func writeVergexOutputFormat(sb *strings.Builder, accountEquity float64, riskCon
|
||||
sb.WriteString("</decision>\n\n")
|
||||
|
||||
if zh {
|
||||
sb.WriteString("## 字段要求\n\n")
|
||||
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,开仓建议 ≥ %d\n", riskControl.MinConfidence))
|
||||
sb.WriteString("- 开仓时必填: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n")
|
||||
sb.WriteString("- 所有数值必须是算好的数字,不能写公式。\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("- 本策略只交易 `%s`,JSON 的 symbol 必须完全等于它。\n", exampleSymbol))
|
||||
sb.WriteString(fmt.Sprintf("- This strategy trades only `%s`; JSON symbol must match it exactly.\n", exampleSymbol))
|
||||
} else {
|
||||
sb.WriteString("- JSON 的 symbol 必须完全来自本轮候选标的或已有持仓;`xyz:` 标的保留前缀,core crypto 标的不要添加 `xyz:` 或 `USDT` 后缀。\n")
|
||||
sb.WriteString("- JSON symbols must exactly match current candidates or existing positions; keep `xyz:` on XYZ instruments, and do not add `xyz:` or `USDT` to core crypto symbols.\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
} else {
|
||||
@@ -446,19 +491,19 @@ 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")
|
||||
sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts; may scale in when confidence ≥ 70\n- Allow larger positions, but must strictly set stop-loss and explain the risk-reward ratio\n\n")
|
||||
} else {
|
||||
sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts; may scale in when confidence ≥ 70\n- Allow larger positions, but must strictly set stop-loss and explain the risk-reward ratio\n\n")
|
||||
}
|
||||
case "conservative":
|
||||
if zh {
|
||||
sb.WriteString("## 模式: 保守\n- 只有当多重信号共振时才开仓\n- 优先保本, 连亏后必须暂停多个周期\n\n")
|
||||
sb.WriteString("## Mode: Conservative\n- Open positions only when multiple signals resonate\n- Prioritize capital preservation; pause for multiple periods after consecutive losses\n\n")
|
||||
} else {
|
||||
sb.WriteString("## Mode: Conservative\n- Open positions only when multiple signals resonate\n- Prioritize capital preservation; pause for multiple periods after consecutive losses\n\n")
|
||||
}
|
||||
case "scalping":
|
||||
if zh {
|
||||
sb.WriteString("## 模式: 短线\n- 关注短期动量, 利润目标较小但要求迅速行动\n- 价格两根 K 线内未按预期走 → 立即减仓或止损\n\n")
|
||||
sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n")
|
||||
} else {
|
||||
sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, smaller profit targets but require quick action\n- If price doesn't move as expected within two bars, immediately reduce position or stop-loss\n\n")
|
||||
}
|
||||
@@ -467,9 +512,9 @@ func writeModeVariant(sb *strings.Builder, variant string, zh bool) {
|
||||
|
||||
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))
|
||||
sb.WriteString("# Hard Constraints (Risk Control)\n\n")
|
||||
sb.WriteString("## CODE ENFORCED (backend validation, cannot be bypassed):\n")
|
||||
sb.WriteString(fmt.Sprintf("- Max Positions: %d instruments simultaneously\n", riskControl.MaxPositions))
|
||||
} else {
|
||||
sb.WriteString("# Hard Constraints (Risk Control)\n\n")
|
||||
sb.WriteString("## CODE ENFORCED (backend validation, cannot be bypassed):\n")
|
||||
@@ -486,14 +531,14 @@ func writeHardConstraints(sb *strings.Builder, accountEquity float64, riskContro
|
||||
maxVal := accountEquity * ratio
|
||||
symLabel := primarySymbol
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- 单仓最大价值 (%s): %.0f USDT (= 权益 %.0f × %.1fx)\n", symLabel, maxVal, accountEquity, ratio))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (%s): max %.0f USDT (= equity %.0f × %.1fx)\n", symLabel, maxVal, accountEquity, ratio))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (%s): max %.0f USDT (= equity %.0f × %.1fx)\n", symLabel, maxVal, accountEquity, ratio))
|
||||
}
|
||||
} else {
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- 单仓最大价值 (山寨币/股票): %.0f USDT (= 权益 %.0f × %.1fx)\n", accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- 单仓最大价值 (BTC/ETH): %.0f USDT (= 权益 %.0f × %.1fx)\n", accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (Altcoin/Stock): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (BTC/ETH): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (Altcoin/Stock): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*altcoinPosValueRatio, accountEquity, altcoinPosValueRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Position Value Limit (BTC/ETH): max %.0f USDT (= equity %.0f × %.1fx)\n", accountEquity*btcEthPosValueRatio, accountEquity, btcEthPosValueRatio))
|
||||
@@ -501,9 +546,9 @@ func writeHardConstraints(sb *strings.Builder, accountEquity float64, riskContro
|
||||
}
|
||||
|
||||
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")
|
||||
sb.WriteString(fmt.Sprintf("- Max Margin Usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min Position Size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
sb.WriteString("## AI GUIDED (recommended):\n")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Max Margin Usage: ≤%.0f%%\n", riskControl.MaxMarginUsage*100))
|
||||
sb.WriteString(fmt.Sprintf("- Min Position Size: ≥%.0f USDT\n\n", riskControl.MinPositionSize))
|
||||
@@ -516,20 +561,20 @@ func writeHardConstraints(sb *strings.Builder, accountEquity float64, riskContro
|
||||
lev = riskControl.BTCETHMaxLeverage
|
||||
}
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- 交易杠杆 (%s): 最高 %dx\n", primarySymbol, lev))
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage (%s): max %dx\n", primarySymbol, lev))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage (%s): max %dx\n", primarySymbol, lev))
|
||||
}
|
||||
} else {
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- 交易杠杆: 山寨币/股票 最高 %dx | BTC/ETH 最高 %dx\n", riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage: Altcoin/Stock max %dx | BTC/ETH max %dx\n", riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Trading Leverage: Altcoin/Stock max %dx | BTC/ETH max %dx\n", riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage))
|
||||
}
|
||||
}
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- 风险回报比: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- 最小置信度: ≥%d 才开仓\n\n", riskControl.MinConfidence))
|
||||
sb.WriteString(fmt.Sprintf("- Risk-Reward Ratio: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min Confidence: ≥%d to open position\n\n", riskControl.MinConfidence))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("- Risk-Reward Ratio: ≥1:%.1f (take_profit / stop_loss)\n", riskControl.MinRiskRewardRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Min Confidence: ≥%d to open position\n\n", riskControl.MinConfidence))
|
||||
@@ -544,13 +589,13 @@ func writeHardConstraints(sb *strings.Builder, accountEquity float64, riskContro
|
||||
}
|
||||
}
|
||||
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")
|
||||
sb.WriteString("## Position Sizing Guidance\n")
|
||||
sb.WriteString("Calculate `position_size_usd` from your confidence and the Position Value Limits above:\n")
|
||||
sb.WriteString("- High confidence (≥85): use 80-100%% of the position value limit\n")
|
||||
sb.WriteString("- Medium confidence (70-84): use 50-80%% of the position value limit\n")
|
||||
sb.WriteString("- Low confidence (60-69): use 30-50%% of the position value limit\n")
|
||||
sb.WriteString(fmt.Sprintf("- Example: equity %.0f × %.1fx = max %.0f USDT\n", accountEquity, exampleRatio, accountEquity*exampleRatio))
|
||||
sb.WriteString("- **DO NOT** just use available_balance as position_size_usd. Use the Position Value Limit!\n\n")
|
||||
} else {
|
||||
sb.WriteString("## Position Sizing Guidance\n")
|
||||
sb.WriteString("Calculate `position_size_usd` from your confidence and the Position Value Limits above:\n")
|
||||
@@ -566,21 +611,21 @@ func writeOutputFormat(sb *strings.Builder, accountEquity, btcEthPosValueRatio f
|
||||
// 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")
|
||||
sb.WriteString("**Must use XML tags <reasoning> and <decision> to separate chain of thought and decision JSON, avoiding parsing errors**\n\n")
|
||||
} else {
|
||||
sb.WriteString("**Must use XML tags <reasoning> and <decision> to separate chain of thought and decision JSON, avoiding parsing errors**\n\n")
|
||||
}
|
||||
sb.WriteString("## Format Requirements\n\n")
|
||||
sb.WriteString("<reasoning>\n")
|
||||
if zh {
|
||||
sb.WriteString("你的思维链分析...\n- 简明分析你的思考过程\n")
|
||||
sb.WriteString("Your chain of thought analysis...\n- Briefly analyze your thinking process\n")
|
||||
} else {
|
||||
sb.WriteString("Your chain of thought analysis...\n- Briefly analyze your thinking process\n")
|
||||
}
|
||||
sb.WriteString("</reasoning>\n\n")
|
||||
sb.WriteString("<decision>\n")
|
||||
if zh {
|
||||
sb.WriteString("步骤 2: JSON 决策数组\n\n")
|
||||
sb.WriteString("Step 2: JSON decision array\n\n")
|
||||
} else {
|
||||
sb.WriteString("Step 2: JSON decision array\n\n")
|
||||
}
|
||||
@@ -608,13 +653,13 @@ func writeOutputFormat(sb *strings.Builder, accountEquity, btcEthPosValueRatio f
|
||||
sb.WriteString("</decision>\n\n")
|
||||
|
||||
if zh {
|
||||
sb.WriteString("## 字段说明\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 (开仓建议 ≥ %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")
|
||||
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("- **本策略只交易 %s**, JSON 中的 `symbol` 必须**完全等于** `%s`, 不要写成 `%s` 去掉后缀或加 USDT 的变体。\n", primarySymbol, primarySymbol, primarySymbol))
|
||||
sb.WriteString(fmt.Sprintf("- **This strategy trades only %s.** The JSON `symbol` MUST match `%s` exactly — do not write `%s` variants that drop the suffix or add USDT.\n", primarySymbol, primarySymbol, primarySymbol))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
} else {
|
||||
@@ -642,9 +687,9 @@ func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder, zh bool)
|
||||
}
|
||||
|
||||
if zh {
|
||||
sb.WriteString(fmt.Sprintf("- %s 价格序列", kline.PrimaryTimeframe))
|
||||
sb.WriteString(fmt.Sprintf("- %s price series", kline.PrimaryTimeframe))
|
||||
if kline.EnableMultiTimeframe {
|
||||
sb.WriteString(fmt.Sprintf(" + %s K 线序列\n", kline.LongerTimeframe))
|
||||
sb.WriteString(fmt.Sprintf(" + %s K-line series\n", kline.LongerTimeframe))
|
||||
} else {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
@@ -658,50 +703,50 @@ func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder, zh bool)
|
||||
}
|
||||
|
||||
if indicators.EnableEMA {
|
||||
sb.WriteString("- " + label("EMA indicators", "EMA 指标"))
|
||||
sb.WriteString("- " + label("EMA indicators", "EMA indicators"))
|
||||
if len(indicators.EMAPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "周期"), indicators.EMAPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "periods"), indicators.EMAPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if indicators.EnableMACD {
|
||||
sb.WriteString("- " + label("MACD indicators", "MACD 指标") + "\n")
|
||||
sb.WriteString("- " + label("MACD indicators", "MACD indicators") + "\n")
|
||||
}
|
||||
if indicators.EnableRSI {
|
||||
sb.WriteString("- " + label("RSI indicators", "RSI 指标"))
|
||||
sb.WriteString("- " + label("RSI indicators", "RSI indicators"))
|
||||
if len(indicators.RSIPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "周期"), indicators.RSIPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "periods"), indicators.RSIPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if indicators.EnableATR {
|
||||
sb.WriteString("- " + label("ATR indicators", "ATR 指标"))
|
||||
sb.WriteString("- " + label("ATR indicators", "ATR indicators"))
|
||||
if len(indicators.ATRPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "周期"), indicators.ATRPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "periods"), indicators.ATRPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if indicators.EnableBOLL {
|
||||
sb.WriteString("- " + label("Bollinger Bands (BOLL) - Upper/Middle/Lower bands", "布林带 (BOLL) - 上/中/下轨"))
|
||||
sb.WriteString("- " + label("Bollinger Bands (BOLL) - Upper/Middle/Lower bands", "Bollinger Bands (BOLL) - Upper/Middle/Lower bands"))
|
||||
if len(indicators.BOLLPeriods) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "周期"), indicators.BOLLPeriods))
|
||||
sb.WriteString(fmt.Sprintf(" (%s: %v)", label("periods", "periods"), indicators.BOLLPeriods))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if indicators.EnableVolume {
|
||||
sb.WriteString("- " + label("Volume data", "成交量数据") + "\n")
|
||||
sb.WriteString("- " + label("Volume data", "Volume data") + "\n")
|
||||
}
|
||||
if indicators.EnableOI {
|
||||
sb.WriteString("- " + label("Open Interest (OI) data", "持仓量 (OI) 数据") + "\n")
|
||||
sb.WriteString("- " + label("Open Interest (OI) data", "Open Interest (OI) data") + "\n")
|
||||
}
|
||||
if indicators.EnableFundingRate {
|
||||
sb.WriteString("- " + label("Funding rate", "资金费率") + "\n")
|
||||
sb.WriteString("- " + label("Funding rate", "Funding rate") + "\n")
|
||||
}
|
||||
if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseAI500 || e.config.CoinSource.UseOITop {
|
||||
sb.WriteString("- " + label("AI500 / OI_Top filter tags (if available)", "AI500 / OI_Top 过滤标记 (如有)") + "\n")
|
||||
sb.WriteString("- " + label("AI500 / OI_Top filter tags (if available)", "AI500 / OI_Top filter tags (if available)") + "\n")
|
||||
}
|
||||
if indicators.EnableQuantData {
|
||||
sb.WriteString("- " + label("Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)", "量化数据 (机构/散户资金流, 持仓变化, 多周期价格变动)") + "\n")
|
||||
sb.WriteString("- " + label("Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)", "Quantitative data (institutional/retail fund flow, position changes, multi-period price changes)") + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,13 +807,13 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
}
|
||||
|
||||
if lang == LangChinese {
|
||||
sb.WriteString("## 历史交易统计\n")
|
||||
sb.WriteString(fmt.Sprintf("总交易: %d 笔 | 盈利因子: %.2f | 夏普比率: %.2f | 盈亏比: %.2f\n",
|
||||
sb.WriteString("## Historical Trading Statistics\n")
|
||||
sb.WriteString(fmt.Sprintf("Total Trades: %d | Profit Factor: %.2f | Sharpe: %.2f | Win/Loss Ratio: %.2f\n",
|
||||
ctx.TradingStats.TotalTrades,
|
||||
ctx.TradingStats.ProfitFactor,
|
||||
ctx.TradingStats.SharpeRatio,
|
||||
winLossRatio))
|
||||
sb.WriteString(fmt.Sprintf("总盈亏: %+.2f USDT | 平均盈利: +%.2f | 平均亏损: -%.2f | 最大回撤: %.1f%%\n",
|
||||
sb.WriteString(fmt.Sprintf("Total PnL: %+.2f USDT | Avg Win: +%.2f | Avg Loss: -%.2f | Max Drawdown: %.1f%%\n",
|
||||
ctx.TradingStats.TotalPnL,
|
||||
ctx.TradingStats.AvgWin,
|
||||
ctx.TradingStats.AvgLoss,
|
||||
@@ -776,13 +821,13 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string {
|
||||
|
||||
// Performance hints based on profit factor, sharpe, and drawdown
|
||||
if ctx.TradingStats.ProfitFactor >= 1.5 && ctx.TradingStats.SharpeRatio >= 1 {
|
||||
sb.WriteString("表现: 良好 - 保持当前策略\n")
|
||||
sb.WriteString("Performance: GOOD - maintain current strategy\n")
|
||||
} else if ctx.TradingStats.ProfitFactor < 1 {
|
||||
sb.WriteString("表现: 需改进 - 提高盈亏比,优化止盈止损\n")
|
||||
sb.WriteString("Performance: NEEDS IMPROVEMENT - improve win/loss ratio, optimize TP/SL\n")
|
||||
} else if ctx.TradingStats.MaxDrawdownPct > 30 {
|
||||
sb.WriteString("表现: 风险偏高 - 减少仓位,控制回撤\n")
|
||||
sb.WriteString("Performance: HIGH RISK - reduce position size, control drawdown\n")
|
||||
} else {
|
||||
sb.WriteString("表现: 正常 - 有优化空间\n")
|
||||
sb.WriteString("Performance: NORMAL - room for optimization\n")
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("## Historical Trading Statistics\n")
|
||||
|
||||
@@ -11,8 +11,8 @@ 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 = "只做多,不做空。"
|
||||
cfg.PromptSections.RoleDefinition = "# You are a professional Hyperliquid USDC multi-asset trading AI"
|
||||
cfg.CustomPrompt = "Long only, no shorts."
|
||||
|
||||
engine := NewStrategyEngine(&cfg)
|
||||
prompt := engine.BuildSystemPrompt(30, "balanced")
|
||||
@@ -29,8 +29,8 @@ func TestBuildSystemPromptUsesVergexClaw402Prompt(t *testing.T) {
|
||||
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, "every open position must use exactly 5x") {
|
||||
t.Fatalf("prompt should force 5x 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)
|
||||
@@ -39,9 +39,9 @@ func TestBuildSystemPromptUsesVergexClaw402Prompt(t *testing.T) {
|
||||
t.Fatalf("system prompt must be English-only, got CJK text:\n%s", prompt)
|
||||
}
|
||||
legacyPhrases := []string{
|
||||
"Hyperliquid USDC 多资产交易AI",
|
||||
"只做多",
|
||||
"山寨币",
|
||||
"Hyperliquid USDC multi-asset trading AI",
|
||||
"Long only",
|
||||
"Altcoin",
|
||||
"BTC/ETH",
|
||||
"LONG-ONLY",
|
||||
"Do not short",
|
||||
@@ -61,11 +61,11 @@ func TestBuildSystemPromptFallsBackToEnglishWhenConfiguredLanguageIsChinese(t *t
|
||||
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 = "中文偏好不应进入系统提示词。"
|
||||
cfg.PromptSections.RoleDefinition = "# You are a Chinese system prompt"
|
||||
cfg.PromptSections.TradingFrequency = "# High-frequency trading\nTrade every minute."
|
||||
cfg.PromptSections.EntryStandards = "# Entry\nOpen positions freely."
|
||||
cfg.PromptSections.DecisionProcess = "# Decision\nOutput directly."
|
||||
cfg.CustomPrompt = "Chinese preference should not enter the system prompt."
|
||||
|
||||
engine := NewStrategyEngine(&cfg)
|
||||
prompt := engine.BuildSystemPrompt(30, "balanced")
|
||||
|
||||
@@ -105,7 +105,7 @@ func formatContextData(ctx *Context, lang Language) string {
|
||||
|
||||
// formatHeaderZH formats header information (Chinese)
|
||||
func formatHeaderZH(ctx *Context) string {
|
||||
return fmt.Sprintf("# 📊 交易决策请求\n\n时间: %s | 周期: #%d | 运行时长: %d 分钟\n\n",
|
||||
return fmt.Sprintf("# 📊 Trading Decision Request\n\nTime: %s | Cycle: #%d | Runtime: %d minutes\n\n",
|
||||
ctx.CurrentTime, ctx.CallCount, ctx.RuntimeMinutes)
|
||||
}
|
||||
|
||||
@@ -114,18 +114,18 @@ func formatAccountZH(ctx *Context) string {
|
||||
acc := ctx.Account
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## 账户状态\n\n")
|
||||
sb.WriteString(fmt.Sprintf("总权益: %.2f USDT | ", acc.TotalEquity))
|
||||
sb.WriteString(fmt.Sprintf("可用余额: %.2f USDT (%.1f%%) | ", acc.AvailableBalance, (acc.AvailableBalance/acc.TotalEquity)*100))
|
||||
sb.WriteString(fmt.Sprintf("总盈亏: %+.2f%% | ", acc.TotalPnLPct))
|
||||
sb.WriteString(fmt.Sprintf("保证金使用率: %.1f%% | ", acc.MarginUsedPct))
|
||||
sb.WriteString(fmt.Sprintf("持仓数: %d\n\n", acc.PositionCount))
|
||||
sb.WriteString("## Account Status\n\n")
|
||||
sb.WriteString(fmt.Sprintf("Total Equity: %.2f USDT | ", acc.TotalEquity))
|
||||
sb.WriteString(fmt.Sprintf("Available Balance: %.2f USDT (%.1f%%) | ", acc.AvailableBalance, (acc.AvailableBalance/acc.TotalEquity)*100))
|
||||
sb.WriteString(fmt.Sprintf("Total PnL: %+.2f%% | ", acc.TotalPnLPct))
|
||||
sb.WriteString(fmt.Sprintf("Margin Usage: %.1f%% | ", acc.MarginUsedPct))
|
||||
sb.WriteString(fmt.Sprintf("Position Count: %d\n\n", acc.PositionCount))
|
||||
|
||||
// Add risk warnings
|
||||
if acc.MarginUsedPct > 70 {
|
||||
sb.WriteString("⚠️ **风险警告**: 保证金使用率 > 70%,处于高风险状态!\n\n")
|
||||
sb.WriteString("⚠️ **Risk Warning**: Margin usage > 70%, in a high-risk state!\n\n")
|
||||
} else if acc.MarginUsedPct > 50 {
|
||||
sb.WriteString("⚠️ **风险提示**: 保证金使用率 > 50%,建议谨慎开仓\n\n")
|
||||
sb.WriteString("⚠️ **Risk Notice**: Margin usage > 50%, open positions with caution\n\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
@@ -134,7 +134,7 @@ func formatAccountZH(ctx *Context) string {
|
||||
// formatTradingStatsZH formats historical trading statistics (Chinese)
|
||||
func formatTradingStatsZH(stats *TradingStats) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## 历史交易统计\n\n")
|
||||
sb.WriteString("## Historical Trading Statistics\n\n")
|
||||
|
||||
// Win/loss ratio calculation
|
||||
var winLossRatio float64
|
||||
@@ -143,49 +143,49 @@ func formatTradingStatsZH(stats *TradingStats) string {
|
||||
}
|
||||
|
||||
// Metric definitions (focusing on core metrics, excluding win rate)
|
||||
sb.WriteString("**指标说明**:\n")
|
||||
sb.WriteString("- 盈利因子: 总盈利 ÷ 总亏损(>1表示盈利,>1.5为良好,>2为优秀)\n")
|
||||
sb.WriteString("- 夏普比率: (平均收益 - 无风险收益) ÷ 收益标准差(>1良好,>2优秀)\n")
|
||||
sb.WriteString("- 盈亏比: 平均盈利 ÷ 平均亏损(>1.5为良好,>2为优秀)\n")
|
||||
sb.WriteString("- 最大回撤: 资金曲线从峰值到谷底的最大跌幅(<20%为低风险)\n\n")
|
||||
sb.WriteString("**Metric Definitions**:\n")
|
||||
sb.WriteString("- Profit Factor: Total Profit ÷ Total Loss (>1 means profitable, >1.5 good, >2 excellent)\n")
|
||||
sb.WriteString("- Sharpe Ratio: (Avg Return - Risk-free Return) ÷ Std Dev of Returns (>1 good, >2 excellent)\n")
|
||||
sb.WriteString("- Win/Loss Ratio: Avg Win ÷ Avg Loss (>1.5 good, >2 excellent)\n")
|
||||
sb.WriteString("- Max Drawdown: Largest decline of the equity curve from peak to trough (<20% is low risk)\n\n")
|
||||
|
||||
// Data values
|
||||
sb.WriteString("**当前数据**:\n")
|
||||
sb.WriteString(fmt.Sprintf("- 总交易: %d 笔\n", stats.TotalTrades))
|
||||
sb.WriteString(fmt.Sprintf("- 盈利因子: %.2f\n", stats.ProfitFactor))
|
||||
sb.WriteString(fmt.Sprintf("- 夏普比率: %.2f\n", stats.SharpeRatio))
|
||||
sb.WriteString(fmt.Sprintf("- 盈亏比: %.2f\n", winLossRatio))
|
||||
sb.WriteString(fmt.Sprintf("- 总盈亏: %+.2f USDT\n", stats.TotalPnL))
|
||||
sb.WriteString(fmt.Sprintf("- 平均盈利: +%.2f USDT\n", stats.AvgWin))
|
||||
sb.WriteString(fmt.Sprintf("- 平均亏损: -%.2f USDT\n", stats.AvgLoss))
|
||||
sb.WriteString(fmt.Sprintf("- 最大回撤: %.1f%%\n\n", stats.MaxDrawdownPct))
|
||||
sb.WriteString("**Current Data**:\n")
|
||||
sb.WriteString(fmt.Sprintf("- Total Trades: %d\n", stats.TotalTrades))
|
||||
sb.WriteString(fmt.Sprintf("- Profit Factor: %.2f\n", stats.ProfitFactor))
|
||||
sb.WriteString(fmt.Sprintf("- Sharpe Ratio: %.2f\n", stats.SharpeRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Win/Loss Ratio: %.2f\n", winLossRatio))
|
||||
sb.WriteString(fmt.Sprintf("- Total PnL: %+.2f USDT\n", stats.TotalPnL))
|
||||
sb.WriteString(fmt.Sprintf("- Avg Win: +%.2f USDT\n", stats.AvgWin))
|
||||
sb.WriteString(fmt.Sprintf("- Avg Loss: -%.2f USDT\n", stats.AvgLoss))
|
||||
sb.WriteString(fmt.Sprintf("- Max Drawdown: %.1f%%\n\n", stats.MaxDrawdownPct))
|
||||
|
||||
// Comprehensive analysis and decision guidance
|
||||
sb.WriteString("**决策参考**:\n")
|
||||
sb.WriteString("**Decision Reference**:\n")
|
||||
|
||||
// Provide specific recommendations based on statistics
|
||||
if stats.TotalTrades < 10 {
|
||||
sb.WriteString("- 样本量较小(<10笔),统计结果参考意义有限\n")
|
||||
sb.WriteString("- Small sample size (<10 trades), statistics have limited reference value\n")
|
||||
}
|
||||
|
||||
if stats.ProfitFactor >= 1.5 && stats.SharpeRatio >= 1 {
|
||||
sb.WriteString("- 📈 表现良好: 可以维持当前策略风格\n")
|
||||
sb.WriteString("- 📈 Good performance: you can keep the current strategy style\n")
|
||||
} else if stats.ProfitFactor >= 1.0 {
|
||||
sb.WriteString("- 📊 表现正常: 策略可行但有优化空间\n")
|
||||
sb.WriteString("- 📊 Normal performance: strategy is viable but has room for optimization\n")
|
||||
}
|
||||
|
||||
if stats.ProfitFactor < 1.0 {
|
||||
sb.WriteString("- ⚠️ 盈利因子<1: 亏损大于盈利,需要提高盈亏比,优化止盈止损\n")
|
||||
sb.WriteString("- ⚠️ Profit Factor <1: losses exceed profits, improve win/loss ratio and optimize stops/targets\n")
|
||||
}
|
||||
|
||||
if winLossRatio > 0 && winLossRatio < 1.5 {
|
||||
sb.WriteString("- ⚠️ 盈亏比偏低: 建议让利润奔跑,提高止盈目标\n")
|
||||
sb.WriteString("- ⚠️ Low win/loss ratio: let profits run and raise take-profit targets\n")
|
||||
}
|
||||
|
||||
if stats.MaxDrawdownPct > 30 {
|
||||
sb.WriteString("- ⚠️ 最大回撤过高: 建议降低仓位大小控制风险\n")
|
||||
sb.WriteString("- ⚠️ Max drawdown too high: reduce position size to control risk\n")
|
||||
} else if stats.MaxDrawdownPct < 10 {
|
||||
sb.WriteString("- ✅ 回撤控制良好: 风险管理有效\n")
|
||||
sb.WriteString("- ✅ Drawdown well controlled: risk management is effective\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
@@ -195,16 +195,16 @@ func formatTradingStatsZH(stats *TradingStats) string {
|
||||
// formatRecentTradesZH formats recent trades (Chinese)
|
||||
func formatRecentTradesZH(orders []RecentOrder) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## 最近完成的交易\n\n")
|
||||
sb.WriteString("## Recently Closed Trades\n\n")
|
||||
|
||||
for i, order := range orders {
|
||||
// Determine profit or loss
|
||||
profitOrLoss := "盈利"
|
||||
profitOrLoss := "Profit"
|
||||
if order.RealizedPnL < 0 {
|
||||
profitOrLoss = "亏损"
|
||||
profitOrLoss = "Loss"
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%d. %s %s | 进场 %.4f 出场 %.4f | %s: %+.2f USDT (%+.2f%%) | %s → %s (%s)\n",
|
||||
sb.WriteString(fmt.Sprintf("%d. %s %s | Entry %.4f Exit %.4f | %s: %+.2f USDT (%+.2f%%) | %s → %s (%s)\n",
|
||||
i+1,
|
||||
order.Symbol,
|
||||
order.Side,
|
||||
@@ -226,37 +226,37 @@ func formatRecentTradesZH(orders []RecentOrder) string {
|
||||
// formatCurrentPositionsZH formats current positions (Chinese)
|
||||
func formatCurrentPositionsZH(ctx *Context) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## 当前持仓\n\n")
|
||||
sb.WriteString("## Current Positions\n\n")
|
||||
|
||||
for i, pos := range ctx.Positions {
|
||||
// Calculate drawdown
|
||||
drawdown := pos.UnrealizedPnLPct - pos.PeakPnLPct
|
||||
|
||||
sb.WriteString(fmt.Sprintf("%d. %s %s | ", i+1, pos.Symbol, strings.ToUpper(pos.Side)))
|
||||
sb.WriteString(fmt.Sprintf("进场 %.4f 当前 %.4f | ", pos.EntryPrice, pos.MarkPrice))
|
||||
sb.WriteString(fmt.Sprintf("数量 %.4f | ", pos.Quantity))
|
||||
sb.WriteString(fmt.Sprintf("仓位价值 %.2f USDT | ", pos.Quantity*pos.MarkPrice))
|
||||
sb.WriteString(fmt.Sprintf("盈亏 %+.2f%% | ", pos.UnrealizedPnLPct))
|
||||
sb.WriteString(fmt.Sprintf("盈亏金额 %+.2f USDT | ", pos.UnrealizedPnL))
|
||||
sb.WriteString(fmt.Sprintf("峰值盈亏 %.2f%% | ", pos.PeakPnLPct))
|
||||
sb.WriteString(fmt.Sprintf("杠杆 %dx | ", pos.Leverage))
|
||||
sb.WriteString(fmt.Sprintf("保证金 %.0f USDT | ", pos.MarginUsed))
|
||||
sb.WriteString(fmt.Sprintf("强平价 %.4f\n", pos.LiquidationPrice))
|
||||
sb.WriteString(fmt.Sprintf("Entry %.4f Current %.4f | ", pos.EntryPrice, pos.MarkPrice))
|
||||
sb.WriteString(fmt.Sprintf("Quantity %.4f | ", pos.Quantity))
|
||||
sb.WriteString(fmt.Sprintf("Position Value %.2f USDT | ", pos.Quantity*pos.MarkPrice))
|
||||
sb.WriteString(fmt.Sprintf("PnL %+.2f%% | ", pos.UnrealizedPnLPct))
|
||||
sb.WriteString(fmt.Sprintf("PnL Amount %+.2f USDT | ", pos.UnrealizedPnL))
|
||||
sb.WriteString(fmt.Sprintf("Peak PnL %.2f%% | ", pos.PeakPnLPct))
|
||||
sb.WriteString(fmt.Sprintf("Leverage %dx | ", pos.Leverage))
|
||||
sb.WriteString(fmt.Sprintf("Margin %.0f USDT | ", pos.MarginUsed))
|
||||
sb.WriteString(fmt.Sprintf("Liq Price %.4f\n", pos.LiquidationPrice))
|
||||
|
||||
// Add analysis hints
|
||||
if drawdown < -0.30*pos.PeakPnLPct && pos.PeakPnLPct > 0.02 {
|
||||
sb.WriteString(fmt.Sprintf(" ⚠️ **止盈提示**: 当前盈亏从峰值 %.2f%% 回撤到 %.2f%%,回撤幅度 %.2f%%,建议考虑止盈\n",
|
||||
sb.WriteString(fmt.Sprintf(" ⚠️ **Take-Profit Hint**: Current PnL retraced from peak %.2f%% to %.2f%%, drawdown %.2f%%, consider taking profit\n",
|
||||
pos.PeakPnLPct, pos.UnrealizedPnLPct, (drawdown/pos.PeakPnLPct)*100))
|
||||
}
|
||||
|
||||
if pos.UnrealizedPnLPct < -4.0 {
|
||||
sb.WriteString(" ⚠️ **止损提示**: 亏损接近-5%止损线,建议考虑止损\n")
|
||||
sb.WriteString(" ⚠️ **Stop-Loss Hint**: Loss approaching the -5% stop-loss line, consider stopping out\n")
|
||||
}
|
||||
|
||||
// Show current price (if market data available)
|
||||
if ctx.MarketDataMap != nil {
|
||||
if mdata, ok := ctx.MarketDataMap[pos.Symbol]; ok {
|
||||
sb.WriteString(fmt.Sprintf(" 📈 当前价格: %.4f\n", mdata.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf(" 📈 Current Price: %.4f\n", mdata.CurrentPrice))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ func formatCurrentPositionsZH(ctx *Context) string {
|
||||
// formatCandidateCoinsZH formats candidate coins (Chinese)
|
||||
func formatCandidateCoinsZH(ctx *Context) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("## 候选币种\n\n")
|
||||
sb.WriteString("## Candidate Coins\n\n")
|
||||
|
||||
for i, coin := range ctx.CandidateCoins {
|
||||
sb.WriteString(fmt.Sprintf("### %d. %s\n\n", i+1, coin.Symbol))
|
||||
@@ -277,7 +277,7 @@ func formatCandidateCoinsZH(ctx *Context) string {
|
||||
// Current price
|
||||
if ctx.MarketDataMap != nil {
|
||||
if mdata, ok := ctx.MarketDataMap[coin.Symbol]; ok {
|
||||
sb.WriteString(fmt.Sprintf("当前价格: %.4f\n\n", mdata.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf("Current Price: %.4f\n\n", mdata.CurrentPrice))
|
||||
|
||||
// Kline data (multi-timeframe)
|
||||
if mdata.TimeframeData != nil {
|
||||
@@ -289,7 +289,7 @@ func formatCandidateCoinsZH(ctx *Context) string {
|
||||
// OI data (if available)
|
||||
if ctx.OITopDataMap != nil {
|
||||
if oiData, ok := ctx.OITopDataMap[coin.Symbol]; ok {
|
||||
sb.WriteString(fmt.Sprintf("**持仓量变化**: OI排名 #%d | 变化 %+.2f%% (%+.2fM USDT) | 价格变化 %+.2f%%\n\n",
|
||||
sb.WriteString(fmt.Sprintf("**OI Change**: OI Rank #%d | Change %+.2f%% (%+.2fM USDT) | Price Change %+.2f%%\n\n",
|
||||
oiData.Rank,
|
||||
oiData.OIDeltaPercent,
|
||||
oiData.OIDeltaValue/1_000_000,
|
||||
@@ -297,17 +297,17 @@ func formatCandidateCoinsZH(ctx *Context) string {
|
||||
))
|
||||
|
||||
// OI interpretation
|
||||
oiChange := "增加"
|
||||
oiChange := "increase"
|
||||
if oiData.OIDeltaPercent < 0 {
|
||||
oiChange = "减少"
|
||||
oiChange = "decrease"
|
||||
}
|
||||
priceChange := "上涨"
|
||||
priceChange := "up"
|
||||
if oiData.PriceDeltaPercent < 0 {
|
||||
priceChange = "下跌"
|
||||
priceChange = "down"
|
||||
}
|
||||
|
||||
interpretation := getOIInterpretationZH(oiChange, priceChange)
|
||||
sb.WriteString(fmt.Sprintf("**市场解读**: %s\n\n", interpretation))
|
||||
sb.WriteString(fmt.Sprintf("**Market Interpretation**: %s\n\n", interpretation))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,9 +321,9 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD
|
||||
|
||||
for _, tf := range timeframes {
|
||||
if data, ok := tfData[tf]; ok && len(data.Klines) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("#### %s 时间框架 (从旧到新)\n\n", tf))
|
||||
sb.WriteString(fmt.Sprintf("#### %s Timeframe (oldest to newest)\n\n", tf))
|
||||
sb.WriteString("```\n")
|
||||
sb.WriteString("时间(UTC) 开盘 最高 最低 收盘 成交量\n")
|
||||
sb.WriteString("Time(UTC) Open High Low Close Volume\n")
|
||||
|
||||
// Only show the latest 30 klines
|
||||
startIdx := 0
|
||||
@@ -346,7 +346,7 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD
|
||||
|
||||
// Mark the last kline
|
||||
if len(data.Klines) > 0 {
|
||||
sb.WriteString(" <- 当前\n")
|
||||
sb.WriteString(" <- current\n")
|
||||
}
|
||||
|
||||
sb.WriteString("```\n\n")
|
||||
@@ -356,14 +356,13 @@ func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesD
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
|
||||
// getOIInterpretationZH returns OI change interpretation (Chinese)
|
||||
func getOIInterpretationZH(oiChange, priceChange string) string {
|
||||
if oiChange == "增加" && priceChange == "上涨" {
|
||||
if oiChange == "increase" && priceChange == "up" {
|
||||
return OIInterpretation.OIUp_PriceUp.ZH
|
||||
} else if oiChange == "增加" && priceChange == "下跌" {
|
||||
} else if oiChange == "increase" && priceChange == "down" {
|
||||
return OIInterpretation.OIUp_PriceDown.ZH
|
||||
} else if oiChange == "减少" && priceChange == "上涨" {
|
||||
} else if oiChange == "decrease" && priceChange == "up" {
|
||||
return OIInterpretation.OIDown_PriceUp.ZH
|
||||
} else {
|
||||
return OIInterpretation.OIDown_PriceDown.ZH
|
||||
@@ -621,7 +620,6 @@ func formatKlineDataEN(symbol string, tfData map[string]*market.TimeframeSeriesD
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
|
||||
// getOIInterpretationEN returns OI change interpretation (English)
|
||||
func getOIInterpretationEN(oiChange, priceChange string) string {
|
||||
if oiChange == "increase" && priceChange == "up" {
|
||||
|
||||
@@ -17,24 +17,24 @@ import (
|
||||
|
||||
// GridLevelInfo represents a single grid level's current state
|
||||
type GridLevelInfo struct {
|
||||
Index int `json:"index"` // Level index (0 = lowest)
|
||||
Price float64 `json:"price"` // Target price for this level
|
||||
State string `json:"state"` // "empty", "pending", "filled"
|
||||
Side string `json:"side"` // "buy" or "sell"
|
||||
OrderID string `json:"order_id"` // Current order ID (if pending)
|
||||
OrderQuantity float64 `json:"order_quantity"` // Order quantity
|
||||
PositionSize float64 `json:"position_size"` // Position size (if filled)
|
||||
PositionEntry float64 `json:"position_entry"` // Entry price (if filled)
|
||||
AllocatedUSD float64 `json:"allocated_usd"` // USD allocated to this level
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"` // Unrealized P&L (if filled)
|
||||
Index int `json:"index"` // Level index (0 = lowest)
|
||||
Price float64 `json:"price"` // Target price for this level
|
||||
State string `json:"state"` // "empty", "pending", "filled"
|
||||
Side string `json:"side"` // "buy" or "sell"
|
||||
OrderID string `json:"order_id"` // Current order ID (if pending)
|
||||
OrderQuantity float64 `json:"order_quantity"` // Order quantity
|
||||
PositionSize float64 `json:"position_size"` // Position size (if filled)
|
||||
PositionEntry float64 `json:"position_entry"` // Entry price (if filled)
|
||||
AllocatedUSD float64 `json:"allocated_usd"` // USD allocated to this level
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"` // Unrealized P&L (if filled)
|
||||
}
|
||||
|
||||
// GridContext contains all information needed for AI grid decision making
|
||||
type GridContext struct {
|
||||
// Basic info
|
||||
Symbol string `json:"symbol"`
|
||||
CurrentTime string `json:"current_time"`
|
||||
CurrentPrice float64 `json:"current_price"`
|
||||
Symbol string `json:"symbol"`
|
||||
CurrentTime string `json:"current_time"`
|
||||
CurrentPrice float64 `json:"current_price"`
|
||||
|
||||
// Grid configuration
|
||||
GridCount int `json:"grid_count"`
|
||||
@@ -52,22 +52,22 @@ type GridContext struct {
|
||||
IsPaused bool `json:"is_paused"`
|
||||
|
||||
// Market data
|
||||
ATR14 float64 `json:"atr14"`
|
||||
BollingerUpper float64 `json:"bollinger_upper"`
|
||||
ATR14 float64 `json:"atr14"`
|
||||
BollingerUpper float64 `json:"bollinger_upper"`
|
||||
BollingerMiddle float64 `json:"bollinger_middle"`
|
||||
BollingerLower float64 `json:"bollinger_lower"`
|
||||
BollingerWidth float64 `json:"bollinger_width"` // Percentage
|
||||
EMA20 float64 `json:"ema20"`
|
||||
EMA50 float64 `json:"ema50"`
|
||||
EMADistance float64 `json:"ema_distance"` // Percentage
|
||||
RSI14 float64 `json:"rsi14"`
|
||||
MACD float64 `json:"macd"`
|
||||
MACDSignal float64 `json:"macd_signal"`
|
||||
MACDHistogram float64 `json:"macd_histogram"`
|
||||
FundingRate float64 `json:"funding_rate"`
|
||||
Volume24h float64 `json:"volume_24h"`
|
||||
PriceChange1h float64 `json:"price_change_1h"`
|
||||
PriceChange4h float64 `json:"price_change_4h"`
|
||||
BollingerLower float64 `json:"bollinger_lower"`
|
||||
BollingerWidth float64 `json:"bollinger_width"` // Percentage
|
||||
EMA20 float64 `json:"ema20"`
|
||||
EMA50 float64 `json:"ema50"`
|
||||
EMADistance float64 `json:"ema_distance"` // Percentage
|
||||
RSI14 float64 `json:"rsi14"`
|
||||
MACD float64 `json:"macd"`
|
||||
MACDSignal float64 `json:"macd_signal"`
|
||||
MACDHistogram float64 `json:"macd_histogram"`
|
||||
FundingRate float64 `json:"funding_rate"`
|
||||
Volume24h float64 `json:"volume_24h"`
|
||||
PriceChange1h float64 `json:"price_change_1h"`
|
||||
PriceChange4h float64 `json:"price_change_4h"`
|
||||
|
||||
// Account info
|
||||
TotalEquity float64 `json:"total_equity"`
|
||||
@@ -102,53 +102,53 @@ func BuildGridSystemPrompt(config *store.GridStrategyConfig, lang string) string
|
||||
}
|
||||
|
||||
func buildGridSystemPromptZh(config *store.GridStrategyConfig) string {
|
||||
return fmt.Sprintf(`# 你是一个专业的网格交易AI
|
||||
return fmt.Sprintf(`# You are a Professional Grid Trading AI
|
||||
|
||||
## 角色定义
|
||||
你是一个经验丰富的网格交易专家,负责管理 %s 的网格交易策略。你的任务是:
|
||||
1. 判断当前市场状态(震荡/趋势/高波动)
|
||||
2. 决定是否需要调整网格或暂停交易
|
||||
3. 管理每个网格层级的订单
|
||||
## Role Definition
|
||||
You are an experienced grid trading expert responsible for managing the grid trading strategy for %s. Your tasks are:
|
||||
1. Determine the current market state (ranging/trending/high volatility)
|
||||
2. Decide whether to adjust the grid or pause trading
|
||||
3. Manage orders at each grid level
|
||||
|
||||
## 网格配置
|
||||
- 交易对: %s
|
||||
- 网格层数: %d
|
||||
- 总投资: %.2f USDT
|
||||
- 杠杆: %dx
|
||||
- 价格分布: %s
|
||||
## Grid Configuration
|
||||
- Trading Pair: %s
|
||||
- Grid Levels: %d
|
||||
- Total Investment: %.2f USDT
|
||||
- Leverage: %dx
|
||||
- Price Distribution: %s
|
||||
|
||||
## 决策规则
|
||||
## Decision Rules
|
||||
|
||||
### 市场状态判断
|
||||
- **震荡市场** (适合网格): 布林带宽度 < 3%%, EMA20/50 距离 < 1%%, 价格在布林带中轨附近
|
||||
- **趋势市场** (暂停网格): 布林带宽度 > 4%%, EMA20/50 距离 > 2%%, 价格持续突破布林带
|
||||
- **高波动市场** (谨慎): ATR异常放大, 价格剧烈波动
|
||||
### Market State Judgment
|
||||
- **Ranging Market** (suitable for grid): Bollinger band width < 3%%, EMA20/50 distance < 1%%, price near the Bollinger middle band
|
||||
- **Trending Market** (pause grid): Bollinger band width > 4%%, EMA20/50 distance > 2%%, price continuously breaking out of the Bollinger bands
|
||||
- **High Volatility Market** (caution): abnormally expanding ATR, sharp price swings
|
||||
|
||||
### 可执行的操作
|
||||
- place_buy_limit: 在指定价格下买入限价单
|
||||
- place_sell_limit: 在指定价格下卖出限价单
|
||||
- cancel_order: 取消指定订单
|
||||
- cancel_all_orders: 取消所有订单
|
||||
- pause_grid: 暂停网格交易(趋势市场时)
|
||||
- resume_grid: 恢复网格交易(震荡市场时)
|
||||
- adjust_grid: 调整网格边界
|
||||
- hold: 保持当前状态不操作
|
||||
### Available Actions
|
||||
- place_buy_limit: place a buy limit order at the specified price
|
||||
- place_sell_limit: place a sell limit order at the specified price
|
||||
- cancel_order: cancel a specified order
|
||||
- cancel_all_orders: cancel all orders
|
||||
- pause_grid: pause grid trading (in a trending market)
|
||||
- resume_grid: resume grid trading (in a ranging market)
|
||||
- adjust_grid: adjust the grid boundaries
|
||||
- hold: keep the current state, take no action
|
||||
|
||||
## 输出格式
|
||||
输出JSON数组,每个决策包含:
|
||||
- symbol: 交易对
|
||||
- action: 操作类型
|
||||
- price: 价格(限价单用)
|
||||
- quantity: 数量
|
||||
- level_index: 网格层级索引
|
||||
- order_id: 订单ID(取消订单用)
|
||||
- confidence: 置信度 0-100
|
||||
- reasoning: 决策理由
|
||||
## Output Format
|
||||
Output a JSON array; each decision contains:
|
||||
- symbol: trading pair
|
||||
- action: action type
|
||||
- price: price (for limit orders)
|
||||
- quantity: quantity
|
||||
- level_index: grid level index
|
||||
- order_id: order ID (for canceling orders)
|
||||
- confidence: confidence 0-100
|
||||
- reasoning: decision reasoning
|
||||
|
||||
示例:
|
||||
Example:
|
||||
[
|
||||
{"symbol": "BTCUSDT", "action": "place_buy_limit", "price": 94000, "quantity": 0.01, "level_index": 2, "confidence": 85, "reasoning": "第2层价格接近,下买单"},
|
||||
{"symbol": "BTCUSDT", "action": "hold", "confidence": 90, "reasoning": "市场震荡,保持当前网格"}
|
||||
{"symbol": "BTCUSDT", "action": "place_buy_limit", "price": 94000, "quantity": 0.01, "level_index": 2, "confidence": 85, "reasoning": "Level 2 price is near, place a buy order"},
|
||||
{"symbol": "BTCUSDT", "action": "hold", "confidence": 90, "reasoning": "Market is ranging, keep the current grid"}
|
||||
]
|
||||
`, config.Symbol, config.Symbol, config.GridCount, config.TotalInvestment, config.Leverage, config.Distribution)
|
||||
}
|
||||
@@ -216,26 +216,26 @@ func BuildGridUserPrompt(ctx *GridContext, lang string) string {
|
||||
func buildGridUserPromptZh(ctx *GridContext) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## 当前时间: %s\n\n", ctx.CurrentTime))
|
||||
sb.WriteString(fmt.Sprintf("## Current Time: %s\n\n", ctx.CurrentTime))
|
||||
|
||||
// Market data section
|
||||
sb.WriteString("## 市场数据\n")
|
||||
sb.WriteString(fmt.Sprintf("- 当前价格: $%.2f\n", ctx.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf("- 1小时涨跌: %.2f%%\n", ctx.PriceChange1h))
|
||||
sb.WriteString(fmt.Sprintf("- 4小时涨跌: %.2f%%\n", ctx.PriceChange4h))
|
||||
sb.WriteString("## Market Data\n")
|
||||
sb.WriteString(fmt.Sprintf("- Current Price: $%.2f\n", ctx.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf("- 1h Change: %.2f%%\n", ctx.PriceChange1h))
|
||||
sb.WriteString(fmt.Sprintf("- 4h Change: %.2f%%\n", ctx.PriceChange4h))
|
||||
sb.WriteString(fmt.Sprintf("- ATR14: $%.2f (%.2f%%)\n", ctx.ATR14, ctx.ATR14/ctx.CurrentPrice*100))
|
||||
sb.WriteString(fmt.Sprintf("- 布林带: 上轨 $%.2f, 中轨 $%.2f, 下轨 $%.2f\n", ctx.BollingerUpper, ctx.BollingerMiddle, ctx.BollingerLower))
|
||||
sb.WriteString(fmt.Sprintf("- 布林带宽度: %.2f%%\n", ctx.BollingerWidth))
|
||||
sb.WriteString(fmt.Sprintf("- EMA20: $%.2f, EMA50: $%.2f, 距离: %.2f%%\n", ctx.EMA20, ctx.EMA50, ctx.EMADistance))
|
||||
sb.WriteString(fmt.Sprintf("- Bollinger Bands: Upper $%.2f, Middle $%.2f, Lower $%.2f\n", ctx.BollingerUpper, ctx.BollingerMiddle, ctx.BollingerLower))
|
||||
sb.WriteString(fmt.Sprintf("- Bollinger Width: %.2f%%\n", ctx.BollingerWidth))
|
||||
sb.WriteString(fmt.Sprintf("- EMA20: $%.2f, EMA50: $%.2f, Distance: %.2f%%\n", ctx.EMA20, ctx.EMA50, ctx.EMADistance))
|
||||
sb.WriteString(fmt.Sprintf("- RSI14: %.1f\n", ctx.RSI14))
|
||||
sb.WriteString(fmt.Sprintf("- MACD: %.4f, Signal: %.4f, Histogram: %.4f\n", ctx.MACD, ctx.MACDSignal, ctx.MACDHistogram))
|
||||
sb.WriteString(fmt.Sprintf("- 资金费率: %.4f%%\n", ctx.FundingRate*100))
|
||||
sb.WriteString(fmt.Sprintf("- Funding Rate: %.4f%%\n", ctx.FundingRate*100))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Box Indicator Section
|
||||
if ctx.BoxData != nil {
|
||||
sb.WriteString("## 箱体指标 (唐奇安通道)\n\n")
|
||||
sb.WriteString("| 箱体级别 | 上轨 | 下轨 | 宽度 |\n")
|
||||
sb.WriteString("## Box Indicator (Donchian Channel)\n\n")
|
||||
sb.WriteString("| Box Level | Upper | Lower | Width |\n")
|
||||
sb.WriteString("|----------|------|------|------|\n")
|
||||
|
||||
shortWidth := 0.0
|
||||
@@ -248,59 +248,59 @@ func buildGridUserPromptZh(ctx *GridContext) string {
|
||||
longWidth = (ctx.BoxData.LongUpper - ctx.BoxData.LongLower) / ctx.BoxData.CurrentPrice * 100
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("| 短期 (3天) | %.2f | %.2f | %.2f%% |\n",
|
||||
sb.WriteString(fmt.Sprintf("| Short (3d) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.ShortUpper, ctx.BoxData.ShortLower, shortWidth))
|
||||
sb.WriteString(fmt.Sprintf("| 中期 (10天) | %.2f | %.2f | %.2f%% |\n",
|
||||
sb.WriteString(fmt.Sprintf("| Mid (10d) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.MidUpper, ctx.BoxData.MidLower, midWidth))
|
||||
sb.WriteString(fmt.Sprintf("| 长期 (21天) | %.2f | %.2f | %.2f%% |\n",
|
||||
sb.WriteString(fmt.Sprintf("| Long (21d) | %.2f | %.2f | %.2f%% |\n",
|
||||
ctx.BoxData.LongUpper, ctx.BoxData.LongLower, longWidth))
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\n当前价格: %.2f\n", ctx.BoxData.CurrentPrice))
|
||||
sb.WriteString(fmt.Sprintf("\nCurrent Price: %.2f\n", ctx.BoxData.CurrentPrice))
|
||||
|
||||
// Check position relative to boxes
|
||||
price := ctx.BoxData.CurrentPrice
|
||||
if price > ctx.BoxData.LongUpper || price < ctx.BoxData.LongLower {
|
||||
sb.WriteString("⚠️ 突破: 价格突破长期箱体!\n")
|
||||
sb.WriteString("⚠️ Breakout: price broke out of the long-term box!\n")
|
||||
} else if price > ctx.BoxData.MidUpper || price < ctx.BoxData.MidLower {
|
||||
sb.WriteString("⚠️ 警告: 价格接近长期箱体边界\n")
|
||||
sb.WriteString("⚠️ Warning: price is approaching the long-term box boundary\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Account section
|
||||
sb.WriteString("## 账户状态\n")
|
||||
sb.WriteString(fmt.Sprintf("- 总权益: $%.2f\n", ctx.TotalEquity))
|
||||
sb.WriteString(fmt.Sprintf("- 可用余额: $%.2f\n", ctx.AvailableBalance))
|
||||
sb.WriteString(fmt.Sprintf("- 当前持仓: %.4f (净头寸)\n", ctx.CurrentPosition))
|
||||
sb.WriteString(fmt.Sprintf("- 未实现盈亏: $%.2f\n", ctx.UnrealizedPnL))
|
||||
sb.WriteString("## Account Status\n")
|
||||
sb.WriteString(fmt.Sprintf("- Total Equity: $%.2f\n", ctx.TotalEquity))
|
||||
sb.WriteString(fmt.Sprintf("- Available Balance: $%.2f\n", ctx.AvailableBalance))
|
||||
sb.WriteString(fmt.Sprintf("- Current Position: %.4f (net position)\n", ctx.CurrentPosition))
|
||||
sb.WriteString(fmt.Sprintf("- Unrealized PnL: $%.2f\n", ctx.UnrealizedPnL))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid state section
|
||||
sb.WriteString("## 网格状态\n")
|
||||
sb.WriteString(fmt.Sprintf("- 网格范围: $%.2f - $%.2f\n", ctx.LowerPrice, ctx.UpperPrice))
|
||||
sb.WriteString(fmt.Sprintf("- 网格间距: $%.2f\n", ctx.GridSpacing))
|
||||
sb.WriteString(fmt.Sprintf("- 活跃订单数: %d\n", ctx.ActiveOrderCount))
|
||||
sb.WriteString(fmt.Sprintf("- 已成交层数: %d\n", ctx.FilledLevelCount))
|
||||
sb.WriteString(fmt.Sprintf("- 网格已暂停: %v\n", ctx.IsPaused))
|
||||
sb.WriteString("## Grid State\n")
|
||||
sb.WriteString(fmt.Sprintf("- Grid Range: $%.2f - $%.2f\n", ctx.LowerPrice, ctx.UpperPrice))
|
||||
sb.WriteString(fmt.Sprintf("- Grid Spacing: $%.2f\n", ctx.GridSpacing))
|
||||
sb.WriteString(fmt.Sprintf("- Active Orders: %d\n", ctx.ActiveOrderCount))
|
||||
sb.WriteString(fmt.Sprintf("- Filled Levels: %d\n", ctx.FilledLevelCount))
|
||||
sb.WriteString(fmt.Sprintf("- Grid Paused: %v\n", ctx.IsPaused))
|
||||
if ctx.CurrentDirection != "" {
|
||||
directionDescZh := map[string]string{
|
||||
"neutral": "中性 (50%买+50%卖)",
|
||||
"long": "做多 (100%买)",
|
||||
"short": "做空 (100%卖)",
|
||||
"long_bias": "偏多 (70%买+30%卖)",
|
||||
"short_bias": "偏空 (30%买+70%卖)",
|
||||
"neutral": "Neutral (50% buy + 50% sell)",
|
||||
"long": "Long (100% buy)",
|
||||
"short": "Short (100% sell)",
|
||||
"long_bias": "Long bias (70% buy + 30% sell)",
|
||||
"short_bias": "Short bias (30% buy + 70% sell)",
|
||||
}
|
||||
desc := directionDescZh[ctx.CurrentDirection]
|
||||
if desc == "" {
|
||||
desc = ctx.CurrentDirection
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("- 网格方向: %s\n", desc))
|
||||
sb.WriteString(fmt.Sprintf("- Grid Direction: %s\n", desc))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Grid levels detail
|
||||
sb.WriteString("## 网格层级详情\n")
|
||||
sb.WriteString("| 层级 | 价格 | 状态 | 方向 | 订单数量 | 持仓数量 | 未实现盈亏 |\n")
|
||||
sb.WriteString("## Grid Level Details\n")
|
||||
sb.WriteString("| Level | Price | State | Side | Order Qty | Position Qty | Unrealized PnL |\n")
|
||||
sb.WriteString("|------|------|------|------|----------|----------|------------|\n")
|
||||
for _, level := range ctx.Levels {
|
||||
sb.WriteString(fmt.Sprintf("| %d | $%.2f | %s | %s | %.4f | %.4f | $%.2f |\n",
|
||||
@@ -310,16 +310,16 @@ func buildGridUserPromptZh(ctx *GridContext) string {
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Performance section
|
||||
sb.WriteString("## 绩效统计\n")
|
||||
sb.WriteString(fmt.Sprintf("- 总利润: $%.2f\n", ctx.TotalProfit))
|
||||
sb.WriteString(fmt.Sprintf("- 总交易次数: %d\n", ctx.TotalTrades))
|
||||
sb.WriteString(fmt.Sprintf("- 胜率: %.1f%%\n", float64(ctx.WinningTrades)/float64(max(ctx.TotalTrades, 1))*100))
|
||||
sb.WriteString(fmt.Sprintf("- 最大回撤: %.2f%%\n", ctx.MaxDrawdown))
|
||||
sb.WriteString(fmt.Sprintf("- 今日盈亏: $%.2f\n", ctx.DailyPnL))
|
||||
sb.WriteString("## Performance Statistics\n")
|
||||
sb.WriteString(fmt.Sprintf("- Total Profit: $%.2f\n", ctx.TotalProfit))
|
||||
sb.WriteString(fmt.Sprintf("- Total Trades: %d\n", ctx.TotalTrades))
|
||||
sb.WriteString(fmt.Sprintf("- Win Rate: %.1f%%\n", float64(ctx.WinningTrades)/float64(max(ctx.TotalTrades, 1))*100))
|
||||
sb.WriteString(fmt.Sprintf("- Max Drawdown: %.2f%%\n", ctx.MaxDrawdown))
|
||||
sb.WriteString(fmt.Sprintf("- Today's PnL: $%.2f\n", ctx.DailyPnL))
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("## 请分析以上数据,做出网格交易决策\n")
|
||||
sb.WriteString("输出JSON数组格式的决策列表。\n")
|
||||
sb.WriteString("## Analyze the data above and make grid trading decisions\n")
|
||||
sb.WriteString("Output the decision list as a JSON array.\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -541,9 +541,9 @@ func isValidGridAction(action string) bool {
|
||||
"adjust_grid": true,
|
||||
"hold": true,
|
||||
// Also support standard actions for compatibility
|
||||
"open_long": true,
|
||||
"open_short": true,
|
||||
"close_long": true,
|
||||
"open_long": true,
|
||||
"open_short": true,
|
||||
"close_long": true,
|
||||
"close_short": true,
|
||||
}
|
||||
return validActions[action]
|
||||
|
||||
@@ -41,43 +41,43 @@ func (pb *PromptBuilder) BuildUserPrompt(ctx *Context) string {
|
||||
return formattedData + pb.getDecisionRequirementsEN()
|
||||
}
|
||||
|
||||
// ========== Chinese Prompts ==========
|
||||
// ========== Chinese Prompts (translated to English) ==========
|
||||
|
||||
func (pb *PromptBuilder) buildSystemPromptZH() string {
|
||||
return `你是一个专业的量化交易AI助手,负责分析市场数据并做出交易决策。
|
||||
return `You are a professional quantitative trading AI assistant, responsible for analyzing market data and making trading decisions.
|
||||
|
||||
## 你的任务
|
||||
## Your Tasks
|
||||
|
||||
1. **分析账户状态**: 评估当前风险水平、保证金使用率、持仓情况
|
||||
2. **分析当前持仓**: 判断是否需要止盈、止损、加仓或持有
|
||||
3. **分析候选币种**: 评估新的交易机会,结合技术分析和资金流向
|
||||
4. **做出决策**: 输出明确的交易决策,包含详细的推理过程
|
||||
1. **Analyze account status**: Evaluate current risk level, margin usage, and position status
|
||||
2. **Analyze current positions**: Decide whether to take profit, stop loss, add to position, or hold
|
||||
3. **Analyze candidate symbols**: Evaluate new trading opportunities, combining technical analysis and capital flow
|
||||
4. **Make decisions**: Output clear trading decisions with detailed reasoning
|
||||
|
||||
## 决策原则
|
||||
## Decision Principles
|
||||
|
||||
### 风险优先
|
||||
- 保证金使用率不得超过30%
|
||||
- 单个持仓亏损达到-5%必须止损
|
||||
- 优先保护资本,再考虑盈利
|
||||
### Risk First
|
||||
- Margin usage must not exceed 30%
|
||||
- A single position losing -5% must be stopped out
|
||||
- Protect capital first, then consider profit
|
||||
|
||||
### 跟踪止盈
|
||||
- 当持仓盈亏从峰值回撤30%时,考虑部分或全部止盈
|
||||
- 例如:Peak PnL +5%,Current PnL +3.5% → 回撤了30%,应该止盈
|
||||
### Trailing Take-Profit
|
||||
- When position PnL retraces 30% from its peak, consider partial or full take-profit
|
||||
- For example: Peak PnL +5%, Current PnL +3.5% -> retraced 30%, should take profit
|
||||
|
||||
### 顺势交易
|
||||
- 只在多个时间框架趋势一致时进场
|
||||
- 结合持仓量(OI)变化判断资金流向真实性
|
||||
- OI增加+价格上涨 = 强多头趋势
|
||||
- OI减少+价格上涨 = 空头平仓(可能反转)
|
||||
### Trend Following
|
||||
- Enter only when multiple timeframes' trends agree
|
||||
- Use open interest (OI) change to judge the authenticity of capital flow
|
||||
- OI up + price up = strong bullish trend
|
||||
- OI down + price up = short covering (possible reversal)
|
||||
|
||||
### 分批操作
|
||||
- 分批建仓:第一次开仓不超过目标仓位的50%
|
||||
- 分批止盈:盈利3%平33%,盈利5%平50%,盈利8%全平
|
||||
- 只在盈利仓位上加仓,永远不要追亏损
|
||||
### Scaling
|
||||
- Scale in: the first open should not exceed 50% of the target position
|
||||
- Scale out: at +3% profit close 33%, at +5% close 50%, at +8% close all
|
||||
- Only add to profitable positions, never chase losses
|
||||
|
||||
## 输出格式要求
|
||||
## Output Format Requirements
|
||||
|
||||
**必须**使用以下JSON格式输出决策:
|
||||
You **must** output decisions in the following JSON format:
|
||||
|
||||
` + "```json" + `
|
||||
[
|
||||
@@ -89,37 +89,37 @@ func (pb *PromptBuilder) buildSystemPromptZH() string {
|
||||
"stop_loss": 42000,
|
||||
"take_profit": 48000,
|
||||
"confidence": 85,
|
||||
"reasoning": "详细的推理过程,说明为什么做出这个决策"
|
||||
"reasoning": "Detailed reasoning explaining why this decision was made"
|
||||
}
|
||||
]
|
||||
` + "```" + `
|
||||
|
||||
### 字段说明
|
||||
### Field Descriptions
|
||||
|
||||
- **symbol**: 交易对(必需)
|
||||
- **action**: 动作类型(必需)
|
||||
- HOLD: 持有当前仓位
|
||||
- PARTIAL_CLOSE: 部分平仓
|
||||
- FULL_CLOSE: 全部平仓
|
||||
- ADD_POSITION: 在现有仓位上加仓
|
||||
- OPEN_NEW: 开设新仓位
|
||||
- WAIT: 等待,不采取任何行动
|
||||
- **leverage**: 杠杆倍数(开新仓时必需)
|
||||
- **position_size_usd**: 仓位大小(USDT,开新仓时必需)
|
||||
- **stop_loss**: 止损价格(开新仓时建议提供)
|
||||
- **take_profit**: 止盈价格(开新仓时建议提供)
|
||||
- **confidence**: 信心度(0-100)
|
||||
- **reasoning**: 推理过程(必需,必须详细说明决策依据)
|
||||
- **symbol**: trading pair (required)
|
||||
- **action**: action type (required)
|
||||
- HOLD: hold the current position
|
||||
- PARTIAL_CLOSE: partially close the position
|
||||
- FULL_CLOSE: fully close the position
|
||||
- ADD_POSITION: add to an existing position
|
||||
- OPEN_NEW: open a new position
|
||||
- WAIT: wait, take no action
|
||||
- **leverage**: leverage multiple (required when opening a new position)
|
||||
- **position_size_usd**: position size (USDT, required when opening a new position)
|
||||
- **stop_loss**: stop-loss price (recommended when opening a new position)
|
||||
- **take_profit**: take-profit price (recommended when opening a new position)
|
||||
- **confidence**: confidence level (0-100)
|
||||
- **reasoning**: reasoning (required, must explain the decision basis in detail)
|
||||
|
||||
## 重要提醒
|
||||
## Important Reminders
|
||||
|
||||
1. **永远不要**混淆已实现盈亏和未实现盈亏
|
||||
2. **永远记得**考虑杠杆对盈亏的放大作用
|
||||
3. **永远关注**Peak PnL,这是判断止盈的关键指标
|
||||
4. **永远结合**持仓量(OI)变化来判断趋势真实性
|
||||
5. **永远遵守**风险管理规则,保护资本是第一位的
|
||||
1. **Never** confuse realized PnL with unrealized PnL
|
||||
2. **Always remember** to account for leverage amplifying PnL
|
||||
3. **Always watch** Peak PnL, the key metric for take-profit decisions
|
||||
4. **Always combine** open interest (OI) change to judge trend authenticity
|
||||
5. **Always follow** risk management rules; protecting capital comes first
|
||||
|
||||
现在,请仔细分析接下来提供的交易数据,并做出专业的决策。`
|
||||
Now, carefully analyze the trading data provided next and make a professional decision.`
|
||||
}
|
||||
|
||||
func (pb *PromptBuilder) getDecisionRequirementsZH() string {
|
||||
@@ -127,30 +127,30 @@ func (pb *PromptBuilder) getDecisionRequirementsZH() string {
|
||||
|
||||
---
|
||||
|
||||
## 📝 现在请做出决策
|
||||
## 📝 Now Make Your Decision
|
||||
|
||||
### 决策步骤
|
||||
### Decision Steps
|
||||
|
||||
1. **分析账户风险**:
|
||||
- 当前保证金使用率是否在安全范围?
|
||||
- 是否有足够资金开新仓?
|
||||
1. **Analyze account risk**:
|
||||
- Is the current margin usage within a safe range?
|
||||
- Is there enough capital to open new positions?
|
||||
|
||||
2. **分析现有持仓**(如果有):
|
||||
- 是否触发止损条件?
|
||||
- 是否触发跟踪止盈条件?
|
||||
- 是否适合加仓?
|
||||
2. **Analyze existing positions** (if any):
|
||||
- Are stop-loss conditions triggered?
|
||||
- Are trailing take-profit conditions triggered?
|
||||
- Is it suitable to add to the position?
|
||||
|
||||
3. **分析候选币种**(如果有):
|
||||
- 技术形态是否符合进场条件?
|
||||
- 持仓量变化是否支持趋势?
|
||||
- 多个时间框架是否共振?
|
||||
3. **Analyze candidate symbols** (if any):
|
||||
- Does the technical pattern meet entry conditions?
|
||||
- Does the open interest change support the trend?
|
||||
- Do multiple timeframes resonate?
|
||||
|
||||
4. **输出决策**:
|
||||
- 使用规定的JSON格式
|
||||
- 提供详细的推理过程
|
||||
- 给出明确的行动指令
|
||||
4. **Output the decision**:
|
||||
- Use the specified JSON format
|
||||
- Provide detailed reasoning
|
||||
- Give clear action instructions
|
||||
|
||||
### 输出示例
|
||||
### Output Example
|
||||
|
||||
` + "```json" + `
|
||||
[
|
||||
@@ -158,7 +158,7 @@ func (pb *PromptBuilder) getDecisionRequirementsZH() string {
|
||||
"symbol": "PIPPINUSDT",
|
||||
"action": "PARTIAL_CLOSE",
|
||||
"confidence": 85,
|
||||
"reasoning": "当前PnL +2.96%,接近历史峰值+2.99%(回撤仅0.03%)。建议部分平仓锁定利润,因为:1) 持仓时间仅11分钟,已获得3%收益;2) 5分钟K线显示价格接近短期阻力位;3) 成交量开始萎缩,上涨动能减弱。建议平仓50%,剩余仓位设置跟踪止盈在峰值回撤20%处。"
|
||||
"reasoning": "Current PnL +2.96%, close to the all-time peak +2.99% (only 0.03% retracement). Recommend partial close to lock in profit because: 1) holding time is only 11 minutes with 3% gain already; 2) the 5-minute candle shows price near short-term resistance; 3) volume is starting to shrink and upward momentum is weakening. Recommend closing 50%, with the remaining position set to a trailing take-profit at 20% retracement from peak."
|
||||
},
|
||||
{
|
||||
"symbol": "HUSDT",
|
||||
@@ -168,12 +168,12 @@ func (pb *PromptBuilder) getDecisionRequirementsZH() string {
|
||||
"stop_loss": 0.1560,
|
||||
"take_profit": 0.1720,
|
||||
"confidence": 75,
|
||||
"reasoning": "HUSDT在5分钟时间框架突破关键阻力位0.1630,持仓量1小时内增加+1.57M (+0.89%),配合价格上涨+4.92%,符合'OI增加+价格上涨'的强多头模式。15分钟和1小时时间框架均呈现上涨趋势,多周期共振。建议开仓做多,止损设在突破点下方-5%,止盈目标+8%。"
|
||||
"reasoning": "HUSDT broke the key resistance 0.1630 on the 5-minute timeframe, open interest increased +1.57M (+0.89%) within 1 hour, together with a price rise of +4.92%, matching the strong bullish 'OI up + price up' pattern. Both the 15-minute and 1-hour timeframes show an uptrend, multi-period resonance. Recommend opening long, with stop-loss set 5% below the breakout point and take-profit target +8%."
|
||||
}
|
||||
]
|
||||
` + "```" + `
|
||||
|
||||
**请立即输出你的决策(JSON格式)**:`
|
||||
**Output your decision immediately (JSON format)**:`
|
||||
}
|
||||
|
||||
// ========== English Prompts ==========
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestPromptBuilder 测试提示词构建器
|
||||
// TestPromptBuilder tests the prompt builder
|
||||
func TestPromptBuilder(t *testing.T) {
|
||||
t.Run("NewPromptBuilder", func(t *testing.T) {
|
||||
builderZH := NewPromptBuilder(LangChinese)
|
||||
@@ -31,17 +31,17 @@ func TestPromptBuilder(t *testing.T) {
|
||||
t.Fatal("System prompt is empty")
|
||||
}
|
||||
|
||||
// 验证包含关键内容
|
||||
// Verify it contains key content
|
||||
mustContain := []string{
|
||||
"量化交易AI助手",
|
||||
"分析账户状态",
|
||||
"分析当前持仓",
|
||||
"分析候选币种",
|
||||
"做出决策",
|
||||
"风险优先",
|
||||
"跟踪止盈",
|
||||
"顺势交易",
|
||||
"分批操作",
|
||||
"quantitative trading AI assistant",
|
||||
"Analyze account status",
|
||||
"Analyze current positions",
|
||||
"Analyze candidate symbols",
|
||||
"Make decisions",
|
||||
"Risk First",
|
||||
"Trailing Take-Profit",
|
||||
"Trend Following",
|
||||
"Scaling",
|
||||
"JSON",
|
||||
"symbol",
|
||||
"action",
|
||||
@@ -54,7 +54,7 @@ func TestPromptBuilder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证包含所有有效的action类型
|
||||
// Verify it contains all valid action types
|
||||
actions := []string{"HOLD", "PARTIAL_CLOSE", "FULL_CLOSE", "ADD_POSITION", "OPEN_NEW", "WAIT"}
|
||||
for _, action := range actions {
|
||||
if !strings.Contains(systemPrompt, action) {
|
||||
@@ -71,7 +71,7 @@ func TestPromptBuilder(t *testing.T) {
|
||||
t.Fatal("System prompt is empty")
|
||||
}
|
||||
|
||||
// 验证包含关键内容
|
||||
// Verify it contains key content
|
||||
mustContain := []string{
|
||||
"quantitative trading AI",
|
||||
"Analyze Account Status",
|
||||
@@ -96,7 +96,7 @@ func TestPromptBuilder(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("BuildUserPrompt", func(t *testing.T) {
|
||||
// 创建测试上下文
|
||||
// Create test context
|
||||
ctx := createTestContext()
|
||||
|
||||
builderZH := NewPromptBuilder(LangChinese)
|
||||
@@ -106,27 +106,27 @@ func TestPromptBuilder(t *testing.T) {
|
||||
t.Fatal("User prompt is empty")
|
||||
}
|
||||
|
||||
// 验证包含数据字典
|
||||
if !strings.Contains(userPromptZH, "数据字典") {
|
||||
// Verify it contains the data dictionary
|
||||
if !strings.Contains(userPromptZH, "Data Dictionary") {
|
||||
t.Error("User prompt should contain data dictionary")
|
||||
}
|
||||
|
||||
// 验证包含账户信息
|
||||
// Verify it contains account information
|
||||
if !strings.Contains(userPromptZH, "3079.40") { // Equity
|
||||
t.Error("User prompt should contain account equity")
|
||||
}
|
||||
|
||||
// 验证包含持仓信息
|
||||
// Verify it contains position information
|
||||
if !strings.Contains(userPromptZH, "PIPPINUSDT") {
|
||||
t.Error("User prompt should contain position symbol")
|
||||
}
|
||||
|
||||
// 验证包含决策要求
|
||||
if !strings.Contains(userPromptZH, "现在请做出决策") {
|
||||
// Verify it contains decision requirements
|
||||
if !strings.Contains(userPromptZH, "Now Make Your Decision") {
|
||||
t.Error("User prompt should contain decision requirements")
|
||||
}
|
||||
|
||||
// 英文版本
|
||||
// English version
|
||||
builderEN := NewPromptBuilder(LangEnglish)
|
||||
userPromptEN := builderEN.BuildUserPrompt(ctx)
|
||||
|
||||
@@ -140,7 +140,7 @@ func TestPromptBuilder(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidateDecisionFormat 测试决策格式验证
|
||||
// TestValidateDecisionFormat tests decision format validation
|
||||
func TestValidateDecisionFormat(t *testing.T) {
|
||||
t.Run("ValidDecision", func(t *testing.T) {
|
||||
decisions := []Decision{
|
||||
@@ -152,7 +152,7 @@ func TestValidateDecisionFormat(t *testing.T) {
|
||||
StopLoss: 42000,
|
||||
TakeProfit: 48000,
|
||||
Confidence: 85,
|
||||
Reasoning: "详细的推理过程",
|
||||
Reasoning: "Detailed reasoning",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -319,7 +319,7 @@ func TestValidateDecisionFormat(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
// OPEN_NEW需要额外字段
|
||||
// OPEN_NEW requires extra fields
|
||||
if action == "OPEN_NEW" {
|
||||
decisions[0].Leverage = 3
|
||||
decisions[0].PositionSizeUSD = 1000
|
||||
@@ -333,7 +333,7 @@ func TestValidateDecisionFormat(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormatDecisionExample 测试决策示例格式化
|
||||
// TestFormatDecisionExample tests decision example formatting
|
||||
func TestFormatDecisionExample(t *testing.T) {
|
||||
t.Run("Chinese", func(t *testing.T) {
|
||||
example := FormatDecisionExample(LangChinese)
|
||||
@@ -342,7 +342,7 @@ func TestFormatDecisionExample(t *testing.T) {
|
||||
t.Fatal("Decision example is empty")
|
||||
}
|
||||
|
||||
// 应该是有效的JSON
|
||||
// Should be valid JSON
|
||||
if !strings.HasPrefix(strings.TrimSpace(example), "[") {
|
||||
t.Error("Example should be a JSON array")
|
||||
}
|
||||
@@ -359,14 +359,14 @@ func TestFormatDecisionExample(t *testing.T) {
|
||||
t.Fatal("Decision example is empty")
|
||||
}
|
||||
|
||||
// 验证是有效的JSON格式
|
||||
// Verify it is valid JSON format
|
||||
if !strings.HasPrefix(strings.TrimSpace(example), "[") {
|
||||
t.Error("Example should be a JSON array")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkBuildSystemPrompt 性能测试
|
||||
// BenchmarkBuildSystemPrompt performance benchmark
|
||||
func BenchmarkBuildSystemPrompt(b *testing.B) {
|
||||
builder := NewPromptBuilder(LangChinese)
|
||||
|
||||
@@ -384,7 +384,7 @@ func BenchmarkBuildSystemPrompt(b *testing.B) {
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkBuildUserPrompt 性能测试
|
||||
// BenchmarkBuildUserPrompt performance benchmark
|
||||
func BenchmarkBuildUserPrompt(b *testing.B) {
|
||||
builder := NewPromptBuilder(LangChinese)
|
||||
ctx := createTestContext()
|
||||
@@ -403,7 +403,7 @@ func BenchmarkBuildUserPrompt(b *testing.B) {
|
||||
})
|
||||
}
|
||||
|
||||
// createTestContext 创建测试用的交易上下文
|
||||
// createTestContext creates a trading context for tests
|
||||
func createTestContext() *Context {
|
||||
return &Context{
|
||||
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||
|
||||
190
kernel/schema.go
190
kernel/schema.go
@@ -62,156 +62,156 @@ func (d BilingualFieldDef) GetDesc(lang Language) string {
|
||||
var DataDictionary = map[string]map[string]BilingualFieldDef{
|
||||
"AccountMetrics": {
|
||||
"Equity": {
|
||||
NameZH: "总权益",
|
||||
NameZH: "Total Equity",
|
||||
NameEN: "Total Equity",
|
||||
Unit: "USDT",
|
||||
FormulaZH: "可用余额 + 未实现盈亏",
|
||||
FormulaZH: "Available Balance + Unrealized PnL",
|
||||
FormulaEN: "Available Balance + Unrealized PnL",
|
||||
DescZH: "账户的实际净值,包含所有持仓的浮动盈亏",
|
||||
DescZH: "Actual net value of the account, including unrealized P&L of all positions",
|
||||
DescEN: "Actual account value including all unrealized P&L from positions",
|
||||
},
|
||||
"Balance": {
|
||||
NameZH: "可用余额",
|
||||
NameZH: "Available Balance",
|
||||
NameEN: "Available Balance",
|
||||
Unit: "USDT",
|
||||
FormulaZH: "初始资金 + 已实现盈亏",
|
||||
FormulaZH: "Initial Capital + Realized PnL",
|
||||
FormulaEN: "Initial Capital + Realized PnL",
|
||||
DescZH: "可用于开新仓位的资金,不包括已用保证金",
|
||||
DescZH: "Funds available for opening new positions, excluding used margin",
|
||||
DescEN: "Available funds for opening new positions, excluding used margin",
|
||||
},
|
||||
"PnL": {
|
||||
NameZH: "总盈亏百分比",
|
||||
NameZH: "Total PnL Percentage",
|
||||
NameEN: "Total PnL Percentage",
|
||||
Unit: "%",
|
||||
FormulaZH: "(总权益 - 初始资金) / 初始资金 × 100",
|
||||
FormulaZH: "(Total Equity - Initial Capital) / Initial Capital × 100",
|
||||
FormulaEN: "(Total Equity - Initial Capital) / Initial Capital × 100",
|
||||
DescZH: "自系统启动以来的总收益率,+15.87%表示盈利15.87%",
|
||||
DescZH: "Total return since system startup, +15.87% means 15.87% profit",
|
||||
DescEN: "Total return since inception, +15.87% means 15.87% profit",
|
||||
},
|
||||
"Margin": {
|
||||
NameZH: "保证金使用率",
|
||||
NameZH: "Margin Usage Rate",
|
||||
NameEN: "Margin Usage Rate",
|
||||
Unit: "%",
|
||||
FormulaZH: "已用保证金合计 / 总权益 × 100",
|
||||
FormulaZH: "Total Used Margin / Total Equity × 100",
|
||||
FormulaEN: "Total Used Margin / Total Equity × 100",
|
||||
DescZH: "该值越高,账户风险越大。安全值<30%,危险值>70%",
|
||||
DescZH: "The higher this value, the greater the account risk. Safe <30%, Dangerous >70%",
|
||||
DescEN: "Higher value = higher risk. Safe <30%, Dangerous >70%",
|
||||
},
|
||||
},
|
||||
|
||||
"TradeMetrics": {
|
||||
"Entry": {
|
||||
NameZH: "进场价",
|
||||
NameZH: "Entry Price",
|
||||
NameEN: "Entry Price",
|
||||
Unit: "USDT",
|
||||
DescZH: "开仓时的平均价格",
|
||||
DescZH: "Average price when opening the position",
|
||||
DescEN: "Average price when opening position",
|
||||
},
|
||||
"Exit": {
|
||||
NameZH: "出场价",
|
||||
NameZH: "Exit Price",
|
||||
NameEN: "Exit Price",
|
||||
Unit: "USDT",
|
||||
DescZH: "平仓时的平均价格",
|
||||
DescZH: "Average price when closing the position",
|
||||
DescEN: "Average price when closing position",
|
||||
},
|
||||
"Profit": {
|
||||
NameZH: "已实现盈亏",
|
||||
NameZH: "Realized PnL",
|
||||
NameEN: "Realized PnL",
|
||||
Unit: "USDT",
|
||||
FormulaZH: "(出场价 - 进场价) / 进场价 × 杠杆 × 仓位价值",
|
||||
FormulaZH: "(Exit Price - Entry Price) / Entry Price × Leverage × Position Value",
|
||||
FormulaEN: "(Exit Price - Entry Price) / Entry Price × Leverage × Position Value",
|
||||
DescZH: "已平仓交易的实际盈亏,包含手续费。正值=盈利,负值=亏损",
|
||||
DescZH: "Actual P&L of closed trades, including fees. Positive=profit, Negative=loss",
|
||||
DescEN: "Actual profit/loss of closed trades including fees. Positive=profit, Negative=loss",
|
||||
},
|
||||
"PnL%": {
|
||||
NameZH: "盈亏百分比",
|
||||
NameZH: "PnL Percentage",
|
||||
NameEN: "PnL Percentage",
|
||||
Unit: "%",
|
||||
FormulaZH: "(出场价 - 进场价) / 进场价 × 杠杆 × 100",
|
||||
FormulaZH: "(Exit - Entry) / Entry × Leverage × 100",
|
||||
FormulaEN: "(Exit - Entry) / Entry × Leverage × 100",
|
||||
DescZH: "已平仓交易的收益率,+6.71%表示盈利6.71%",
|
||||
DescZH: "Return on a closed trade, +6.71% means 6.71% profit",
|
||||
DescEN: "Return on closed trade, +6.71% means 6.71% profit",
|
||||
},
|
||||
"HoldDuration": {
|
||||
NameZH: "持仓时长",
|
||||
NameZH: "Holding Duration",
|
||||
NameEN: "Holding Duration",
|
||||
Unit: "minutes",
|
||||
DescZH: "从开仓到平仓的时间。<15分钟=超短线,15分钟-4小时=日内,>4小时=波段",
|
||||
DescZH: "Time from open to close. <15min=scalping, 15min-4h=intraday, >4h=swing",
|
||||
DescEN: "Time from open to close. <15min=scalping, 15min-4h=intraday, >4h=swing",
|
||||
},
|
||||
},
|
||||
|
||||
"PositionMetrics": {
|
||||
"UnrealizedPnL%": {
|
||||
NameZH: "未实现盈亏百分比",
|
||||
NameZH: "Unrealized PnL Percentage",
|
||||
NameEN: "Unrealized PnL Percentage",
|
||||
Unit: "%",
|
||||
FormulaZH: "(当前价 - 进场价) / 进场价 × 杠杆 × 100",
|
||||
FormulaZH: "(Current Price - Entry Price) / Entry Price × Leverage × 100",
|
||||
FormulaEN: "(Current Price - Entry Price) / Entry Price × Leverage × 100",
|
||||
DescZH: "当前持仓的浮动盈亏,未平仓前是浮动的",
|
||||
DescZH: "Floating P&L of the current position, fluctuating until closed",
|
||||
DescEN: "Floating P&L of current position, not realized until closed",
|
||||
},
|
||||
"PeakPnL%": {
|
||||
NameZH: "峰值盈亏百分比",
|
||||
NameZH: "Peak PnL Percentage",
|
||||
NameEN: "Peak PnL Percentage",
|
||||
Unit: "%",
|
||||
DescZH: "该持仓曾经达到的最高未实现盈亏。用于判断是否需要止盈",
|
||||
DescZH: "Highest unrealized P&L this position has reached. Used to decide whether to take profit",
|
||||
DescEN: "Historical max unrealized PnL for this position. Used for take-profit decisions",
|
||||
},
|
||||
"Drawdown": {
|
||||
NameZH: "从峰值回撤",
|
||||
NameZH: "Drawdown from Peak",
|
||||
NameEN: "Drawdown from Peak",
|
||||
Unit: "%",
|
||||
FormulaZH: "当前盈亏% - 峰值盈亏%",
|
||||
FormulaZH: "Current PnL% - Peak PnL%",
|
||||
FormulaEN: "Current PnL% - Peak PnL%",
|
||||
DescZH: "负值表示正在回撤。例如:峰值+5%,当前+3%,回撤=-2%",
|
||||
DescZH: "Negative value means pulling back. E.g., Peak +5%, Current +3%, Drawdown = -2%",
|
||||
DescEN: "Negative = pulling back. E.g., Peak +5%, Current +3%, Drawdown = -2%",
|
||||
},
|
||||
"Leverage": {
|
||||
NameZH: "杠杆倍数",
|
||||
NameZH: "Leverage",
|
||||
NameEN: "Leverage",
|
||||
Unit: "x",
|
||||
DescZH: "3x表示价格变动1%,持仓盈亏变动3%。杠杆越高,风险越大",
|
||||
DescZH: "3x means a 1% price move = 3% position PnL. Higher leverage = higher risk",
|
||||
DescEN: "3x means 1% price move = 3% position PnL. Higher leverage = higher risk",
|
||||
},
|
||||
"Margin": {
|
||||
NameZH: "占用保证金",
|
||||
NameZH: "Margin Used",
|
||||
NameEN: "Margin Used",
|
||||
Unit: "USDT",
|
||||
FormulaZH: "仓位价值 / 杠杆",
|
||||
FormulaZH: "Position Value / Leverage",
|
||||
FormulaEN: "Position Value / Leverage",
|
||||
DescZH: "该仓位锁定的保证金金额",
|
||||
DescZH: "Amount of margin locked for this position",
|
||||
DescEN: "Collateral locked for this position",
|
||||
},
|
||||
"LiqPrice": {
|
||||
NameZH: "强平价格",
|
||||
NameZH: "Liquidation Price",
|
||||
NameEN: "Liquidation Price",
|
||||
Unit: "USDT",
|
||||
DescZH: "价格触及此值时会被强制平仓。0.0000表示无爆仓风险",
|
||||
DescZH: "Position will be force-closed when price reaches this value. 0.0000 = no liquidation risk",
|
||||
DescEN: "Price at which position will be force-closed. 0.0000 = no liquidation risk",
|
||||
},
|
||||
},
|
||||
|
||||
"MarketData": {
|
||||
"Volume": {
|
||||
NameZH: "成交量",
|
||||
NameZH: "Volume",
|
||||
NameEN: "Volume",
|
||||
Unit: "base asset",
|
||||
DescZH: "该时间段的交易量",
|
||||
DescZH: "Trading volume in this period",
|
||||
DescEN: "Trading volume in this period",
|
||||
},
|
||||
"OI": {
|
||||
NameZH: "持仓量",
|
||||
NameZH: "Open Interest",
|
||||
NameEN: "Open Interest",
|
||||
Unit: "USDT",
|
||||
DescZH: "未平仓合约的总价值。持仓量增加=资金流入,减少=资金流出",
|
||||
DescZH: "Total value of open contracts. Increasing OI = capital inflow, decreasing = outflow",
|
||||
DescEN: "Total value of open contracts. Increasing OI = capital inflow, decreasing = outflow",
|
||||
},
|
||||
"OIChange": {
|
||||
NameZH: "持仓量变化",
|
||||
NameZH: "OI Change",
|
||||
NameEN: "OI Change",
|
||||
Unit: "USDT & %",
|
||||
DescZH: "1小时内持仓量的变化。用于判断市场真实资金流向",
|
||||
DescZH: "OI change within 1 hour. Used to judge the real market capital flow direction",
|
||||
DescEN: "OI change in 1 hour. Used to determine real capital flow direction",
|
||||
},
|
||||
},
|
||||
@@ -256,30 +256,30 @@ var TradingRules = struct {
|
||||
RiskManagement: map[string]BilingualRuleDef{
|
||||
"MaxMarginUsage": {
|
||||
Value: 0.30,
|
||||
DescZH: "保证金使用率不得超过30%",
|
||||
DescZH: "Margin usage must not exceed 30%",
|
||||
DescEN: "Margin usage must not exceed 30%",
|
||||
ReasonZH: "保留70%的资金应对极端行情和追加保证金",
|
||||
ReasonZH: "Reserve 70% capital for extreme market conditions and margin calls",
|
||||
ReasonEN: "Reserve 70% capital for extreme market conditions and margin calls",
|
||||
},
|
||||
"MaxPositionLoss": {
|
||||
Value: -0.05,
|
||||
DescZH: "单个持仓亏损达到-5%时必须止损",
|
||||
DescZH: "Must stop-loss when single position loss reaches -5%",
|
||||
DescEN: "Must stop-loss when single position loss reaches -5%",
|
||||
ReasonZH: "避免单笔交易造成过大损失",
|
||||
ReasonZH: "Prevent excessive loss from single trade",
|
||||
ReasonEN: "Prevent excessive loss from single trade",
|
||||
},
|
||||
"MaxDailyLoss": {
|
||||
Value: -0.10,
|
||||
DescZH: "单日亏损达到-10%时停止交易",
|
||||
DescZH: "Stop trading when daily loss reaches -10%",
|
||||
DescEN: "Stop trading when daily loss reaches -10%",
|
||||
ReasonZH: "防止情绪化交易导致连续亏损",
|
||||
ReasonZH: "Prevent emotional trading leading to consecutive losses",
|
||||
ReasonEN: "Prevent emotional trading leading to consecutive losses",
|
||||
},
|
||||
"PositionSizeLimit": {
|
||||
Value: 0.15,
|
||||
DescZH: "单个仓位不得超过总权益的15%",
|
||||
DescZH: "Single position must not exceed 15% of total equity",
|
||||
DescEN: "Single position must not exceed 15% of total equity",
|
||||
ReasonZH: "避免过度集中风险",
|
||||
ReasonZH: "Avoid excessive risk concentration",
|
||||
ReasonEN: "Avoid excessive risk concentration",
|
||||
},
|
||||
},
|
||||
@@ -287,16 +287,16 @@ var TradingRules = struct {
|
||||
EntrySignals: map[string]BilingualRuleDef{
|
||||
"VolumeSpike": {
|
||||
Value: 2.0,
|
||||
DescZH: "成交量是平均值的2倍以上时考虑进场",
|
||||
DescZH: "Consider entry when volume is 2x above average",
|
||||
DescEN: "Consider entry when volume is 2x above average",
|
||||
ReasonZH: "放量突破通常意味着强趋势",
|
||||
ReasonZH: "Volume breakout usually indicates strong trend",
|
||||
ReasonEN: "Volume breakout usually indicates strong trend",
|
||||
},
|
||||
"OIChangeThreshold": {
|
||||
Value: 0.02,
|
||||
DescZH: "持仓量1小时内变化超过2%视为显著变化",
|
||||
DescZH: "OI change >2% in 1 hour is considered significant",
|
||||
DescEN: "OI change >2% in 1 hour is considered significant",
|
||||
ReasonZH: "大额资金进出会导致持仓量显著变化",
|
||||
ReasonZH: "Large capital flows cause significant OI changes",
|
||||
ReasonEN: "Large capital flows cause significant OI changes",
|
||||
},
|
||||
},
|
||||
@@ -304,16 +304,16 @@ var TradingRules = struct {
|
||||
ExitSignals: map[string]BilingualRuleDef{
|
||||
"TrailingStop": {
|
||||
Value: 0.30,
|
||||
DescZH: "当盈亏从峰值回撤30%时平仓止盈",
|
||||
DescZH: "Close position when PnL pulls back 30% from peak",
|
||||
DescEN: "Close position when PnL pulls back 30% from peak",
|
||||
ReasonZH: "锁定大部分利润,避免盈利回吐。例如:峰值+5%,回撤到+3.5%时平仓",
|
||||
ReasonZH: "Lock in most profits, avoid profit giveback. E.g., Peak +5%, close at +3.5%",
|
||||
ReasonEN: "Lock in most profits, avoid profit giveback. E.g., Peak +5%, close at +3.5%",
|
||||
},
|
||||
"StopLoss": {
|
||||
Value: -0.05,
|
||||
DescZH: "硬止损设置在-5%",
|
||||
DescZH: "Hard stop-loss at -5%",
|
||||
DescEN: "Hard stop-loss at -5%",
|
||||
ReasonZH: "严格控制单笔最大损失",
|
||||
ReasonZH: "Strictly control maximum single-trade loss",
|
||||
ReasonEN: "Strictly control maximum single-trade loss",
|
||||
},
|
||||
},
|
||||
@@ -321,9 +321,9 @@ var TradingRules = struct {
|
||||
PositionControl: map[string]BilingualRuleDef{
|
||||
"ScaleIn": {
|
||||
Value: map[string]interface{}{"enabled": true, "max_additions": 2, "price_requirement": 0.01},
|
||||
DescZH: "只在盈利仓位上加仓,最多加2次,价格需比平均成本高1%",
|
||||
DescZH: "Only add to winning positions, max 2 additions, price must be 1% above avg cost",
|
||||
DescEN: "Only add to winning positions, max 2 additions, price must be 1% above avg cost",
|
||||
ReasonZH: "顺势加仓,不追亏损",
|
||||
ReasonZH: "Add to winners, never average down losers",
|
||||
ReasonEN: "Add to winners, never average down losers",
|
||||
},
|
||||
"ScaleOut": {
|
||||
@@ -332,9 +332,9 @@ var TradingRules = struct {
|
||||
{"pnl": 0.05, "close_pct": 0.50},
|
||||
{"pnl": 0.08, "close_pct": 1.00},
|
||||
},
|
||||
DescZH: "分批止盈:盈利3%时平33%,5%时平50%,8%时全平",
|
||||
DescZH: "Scale-out: Close 33% at +3%, 50% at +5%, 100% at +8%",
|
||||
DescEN: "Scale-out: Close 33% at +3%, 50% at +5%, 100% at +8%",
|
||||
ReasonZH: "在保证利润的同时让盈利奔跑",
|
||||
ReasonZH: "Lock profits while letting winners run",
|
||||
ReasonEN: "Lock profits while letting winners run",
|
||||
},
|
||||
},
|
||||
@@ -367,28 +367,28 @@ var OIInterpretation = OIInterpretationType{
|
||||
ZH string
|
||||
EN string
|
||||
}{
|
||||
ZH: "强多头趋势(新多单开仓,资金流入做多)",
|
||||
ZH: "Strong bullish trend (new longs opening, capital flowing into long positions)",
|
||||
EN: "Strong bullish trend (new longs opening, capital flowing into long positions)",
|
||||
},
|
||||
OIUp_PriceDown: struct {
|
||||
ZH string
|
||||
EN string
|
||||
}{
|
||||
ZH: "强空头趋势(新空单开仓,资金流入做空)",
|
||||
ZH: "Strong bearish trend (new shorts opening, capital flowing into short positions)",
|
||||
EN: "Strong bearish trend (new shorts opening, capital flowing into short positions)",
|
||||
},
|
||||
OIDown_PriceUp: struct {
|
||||
ZH string
|
||||
EN string
|
||||
}{
|
||||
ZH: "空头平仓(空头止损离场,可能出现反转)",
|
||||
ZH: "Shorts covering (shorts stopped out, potential reversal)",
|
||||
EN: "Shorts covering (shorts stopped out, potential reversal)",
|
||||
},
|
||||
OIDown_PriceDown: struct {
|
||||
ZH string
|
||||
EN string
|
||||
}{
|
||||
ZH: "多头平仓(多头止损离场,可能出现反转)",
|
||||
ZH: "Longs closing (longs stopped out, potential reversal)",
|
||||
EN: "Longs closing (longs stopped out, potential reversal)",
|
||||
},
|
||||
}
|
||||
@@ -407,35 +407,35 @@ type CommonMistake struct {
|
||||
|
||||
var CommonMistakes = []CommonMistake{
|
||||
{
|
||||
ErrorZH: "混淆已实现盈亏和未实现盈亏",
|
||||
ErrorZH: "Confusing realized and unrealized P&L",
|
||||
ErrorEN: "Confusing realized and unrealized P&L",
|
||||
ExampleZH: "将历史交易的盈亏与当前持仓的盈亏相加",
|
||||
ExampleZH: "Adding historical trade P&L with current position P&L",
|
||||
ExampleEN: "Adding historical trade P&L with current position P&L",
|
||||
CorrectZH: "已实现盈亏已经计入账户余额,不应重复计算",
|
||||
CorrectZH: "Realized P&L is already included in account balance, don't double count",
|
||||
CorrectEN: "Realized P&L is already included in account balance, don't double count",
|
||||
},
|
||||
{
|
||||
ErrorZH: "忽略杠杆对盈亏的影响",
|
||||
ErrorZH: "Ignoring leverage's impact on P&L",
|
||||
ErrorEN: "Ignoring leverage's impact on P&L",
|
||||
ExampleZH: "价格涨1%,认为盈利1%",
|
||||
ExampleZH: "Price up 1%, thinking profit is 1%",
|
||||
ExampleEN: "Price up 1%, thinking profit is 1%",
|
||||
CorrectZH: "3x杠杆时,价格涨1%,实际盈利约3%",
|
||||
CorrectZH: "With 3x leverage, 1% price move = ~3% P&L",
|
||||
CorrectEN: "With 3x leverage, 1% price move = ~3% P&L",
|
||||
},
|
||||
{
|
||||
ErrorZH: "不理解Peak PnL的重要性",
|
||||
ErrorZH: "Not understanding Peak PnL's importance",
|
||||
ErrorEN: "Not understanding Peak PnL's importance",
|
||||
ExampleZH: "只关注当前PnL,不关注回撤",
|
||||
ExampleZH: "Only watching current PnL, ignoring drawdown",
|
||||
ExampleEN: "Only watching current PnL, ignoring drawdown",
|
||||
CorrectZH: "当前PnL接近Peak PnL时,应考虑止盈以锁定利润",
|
||||
CorrectZH: "When current PnL near Peak PnL, consider taking profit to lock in gains",
|
||||
CorrectEN: "When current PnL near Peak PnL, consider taking profit to lock in gains",
|
||||
},
|
||||
{
|
||||
ErrorZH: "忽略持仓量(OI)变化",
|
||||
ErrorZH: "Ignoring Open Interest changes",
|
||||
ErrorEN: "Ignoring Open Interest changes",
|
||||
ExampleZH: "只看价格K线,不看资金流向",
|
||||
ExampleZH: "Only watching price candles, not capital flows",
|
||||
ExampleEN: "Only watching price candles, not capital flows",
|
||||
CorrectZH: "结合OI变化判断趋势的真实性和持续性",
|
||||
CorrectZH: "Use OI changes to validate trend authenticity and sustainability",
|
||||
CorrectEN: "Use OI changes to validate trend authenticity and sustainability",
|
||||
},
|
||||
}
|
||||
@@ -452,39 +452,39 @@ func GetSchemaPrompt(lang Language) string {
|
||||
|
||||
// getSchemaPromptZH generates the Chinese prompt
|
||||
func getSchemaPromptZH() string {
|
||||
prompt := "# 📖 数据字典与交易规则\n\n"
|
||||
prompt += "## 📊 字段含义说明\n\n"
|
||||
prompt := "# 📖 Data Dictionary & Trading Rules\n\n"
|
||||
prompt += "## 📊 Field Definitions\n\n"
|
||||
|
||||
// Account metrics
|
||||
prompt += "### 账户指标\n"
|
||||
prompt += "### Account Metrics\n"
|
||||
for key, field := range DataDictionary["AccountMetrics"] {
|
||||
prompt += formatFieldDefZH(key, field)
|
||||
}
|
||||
|
||||
// Trade metrics
|
||||
prompt += "\n### 交易指标\n"
|
||||
prompt += "\n### Trade Metrics\n"
|
||||
for key, field := range DataDictionary["TradeMetrics"] {
|
||||
prompt += formatFieldDefZH(key, field)
|
||||
}
|
||||
|
||||
// Position metrics
|
||||
prompt += "\n### 持仓指标\n"
|
||||
prompt += "\n### Position Metrics\n"
|
||||
for key, field := range DataDictionary["PositionMetrics"] {
|
||||
prompt += formatFieldDefZH(key, field)
|
||||
}
|
||||
|
||||
// Market data
|
||||
prompt += "\n### 市场数据\n"
|
||||
prompt += "\n### Market Data\n"
|
||||
for key, field := range DataDictionary["MarketData"] {
|
||||
prompt += formatFieldDefZH(key, field)
|
||||
}
|
||||
|
||||
// OI interpretation
|
||||
prompt += "\n## 💹 持仓量(OI)变化解读\n\n"
|
||||
prompt += "- **OI增加 + 价格上涨**: " + OIInterpretation.OIUp_PriceUp.ZH + "\n"
|
||||
prompt += "- **OI增加 + 价格下跌**: " + OIInterpretation.OIUp_PriceDown.ZH + "\n"
|
||||
prompt += "- **OI减少 + 价格上涨**: " + OIInterpretation.OIDown_PriceUp.ZH + "\n"
|
||||
prompt += "- **OI减少 + 价格下跌**: " + OIInterpretation.OIDown_PriceDown.ZH + "\n"
|
||||
prompt += "\n## 💹 Open Interest (OI) Change Interpretation\n\n"
|
||||
prompt += "- **OI Up + Price Up**: " + OIInterpretation.OIUp_PriceUp.ZH + "\n"
|
||||
prompt += "- **OI Up + Price Down**: " + OIInterpretation.OIUp_PriceDown.ZH + "\n"
|
||||
prompt += "- **OI Down + Price Up**: " + OIInterpretation.OIDown_PriceUp.ZH + "\n"
|
||||
prompt += "- **OI Down + Price Down**: " + OIInterpretation.OIDown_PriceDown.ZH + "\n"
|
||||
|
||||
return prompt
|
||||
}
|
||||
@@ -530,12 +530,12 @@ func getSchemaPromptEN() string {
|
||||
|
||||
// formatFieldDefZH formats a field definition in Chinese
|
||||
func formatFieldDefZH(key string, field BilingualFieldDef) string {
|
||||
result := "- **" + key + "**(" + field.NameZH + "): " + field.DescZH
|
||||
result := "- **" + key + "** (" + field.NameZH + "): " + field.DescZH
|
||||
if field.FormulaZH != "" {
|
||||
result += " | 公式: `" + field.FormulaZH + "`"
|
||||
result += " | Formula: `" + field.FormulaZH + "`"
|
||||
}
|
||||
if field.Unit != "" {
|
||||
result += " | 单位: " + field.Unit
|
||||
result += " | Unit: " + field.Unit
|
||||
}
|
||||
result += "\n"
|
||||
return result
|
||||
|
||||
@@ -658,7 +658,11 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
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)
|
||||
// Sizing comes from the strategy's own RiskControl (a hardcoded
|
||||
// 6-position × equity×1.2 override used to live here, silently ignoring
|
||||
// the user's configuration). ClampLimits bounds the values and the
|
||||
// margin auto-reduce at order time keeps the book solvent.
|
||||
logger.Infof("✓ Trader %s loaded strategy config: %s (maxPos=%d, posRatio=%.1f)", traderCfg.Name, strategy.Name, strategyConfig.RiskControl.MaxPositions, strategyConfig.RiskControl.AltcoinMaxPositionValueRatio)
|
||||
ensureHyperliquidNativeStrategy(traderCfg.Name, exchangeCfg.ExchangeType, strategyConfig)
|
||||
} else {
|
||||
return fmt.Errorf("trader %s has no strategy configured", traderCfg.Name)
|
||||
|
||||
@@ -78,7 +78,7 @@ var claw402ModelEndpoints = map[string]string{
|
||||
"gemini-3.1-pro": "/api/v1/ai/gemini/chat/3.1-pro",
|
||||
// Kimi
|
||||
"kimi-k2.5": "/api/v1/ai/kimi/chat/k2.5",
|
||||
// Z.AI (智谱)
|
||||
// Z.AI (Zhipu)
|
||||
"glm-5": "/api/v1/ai/zhipu/chat",
|
||||
"glm-5-turbo": "/api/v1/ai/zhipu/chat/turbo",
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ func TestKlineDaily(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Log("=== BTCUSDT 日线 K线数据 (coinank_api 免费接口) ===")
|
||||
t.Log("=== BTCUSDT daily K-line data (coinank_api free endpoint) ===")
|
||||
for i, k := range resp {
|
||||
startTime := time.UnixMilli(k.StartTime).Format("2006-01-02 15:04:05")
|
||||
t.Logf("\n[%d] 时间: %s", i, startTime)
|
||||
t.Logf("\n[%d] Time: %s", i, startTime)
|
||||
t.Logf(" Open: %.2f", k.Open)
|
||||
t.Logf(" High: %.2f", k.High)
|
||||
t.Logf(" Low: %.2f", k.Low)
|
||||
@@ -39,15 +39,15 @@ func TestKlineDaily(t *testing.T) {
|
||||
t.Logf(" Quantity: %.4f (k[7])", k.Quantity)
|
||||
t.Logf(" Count: %.0f (k[8])", k.Count)
|
||||
|
||||
// 计算验证
|
||||
// Calculation verification
|
||||
if k.Close > 0 && k.Volume > 0 {
|
||||
t.Logf(" --- 验证 ---")
|
||||
t.Logf(" --- verification ---")
|
||||
t.Logf(" Volume × Close = %.2f", k.Volume*k.Close)
|
||||
t.Logf(" Quantity / Close = %.4f", k.Quantity/k.Close)
|
||||
}
|
||||
}
|
||||
|
||||
// 打印原始 JSON
|
||||
// Print raw JSON
|
||||
res, _ := json.MarshalIndent(resp, "", " ")
|
||||
fmt.Printf("\n原始 JSON:\n%s\n", res)
|
||||
fmt.Printf("\nRaw JSON:\n%s\n", res)
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ func TestGetCandles_BTC(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Log("=== BTC 日线数据 (Hyperliquid) ===")
|
||||
t.Log("=== BTC daily data (Hyperliquid) ===")
|
||||
for i, c := range candles {
|
||||
openTime := time.UnixMilli(c.OpenTime).Format("2006-01-02 15:04:05")
|
||||
t.Logf("\n[%d] 时间: %s", i, openTime)
|
||||
t.Logf("\n[%d] Time: %s", i, openTime)
|
||||
t.Logf(" Symbol: %s", c.Symbol)
|
||||
t.Logf(" Interval: %s", c.Interval)
|
||||
t.Logf(" Open: %s", c.Open)
|
||||
@@ -30,24 +30,24 @@ func TestGetCandles_BTC(t *testing.T) {
|
||||
t.Logf(" TradeCount: %d", c.TradeCount)
|
||||
}
|
||||
|
||||
// 打印原始 JSON
|
||||
// Print raw JSON
|
||||
res, _ := json.MarshalIndent(candles, "", " ")
|
||||
fmt.Printf("\n原始 JSON:\n%s\n", res)
|
||||
fmt.Printf("\nRaw JSON:\n%s\n", res)
|
||||
}
|
||||
|
||||
func TestGetCandles_TSLA(t *testing.T) {
|
||||
client := NewClient()
|
||||
|
||||
// 测试股票永续合约 - 使用 xyz dex
|
||||
// Test stock perpetual contracts - using xyz dex
|
||||
candles, err := client.GetCandles(context.TODO(), "TSLA", "1d", 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Log("=== TSLA 日线数据 (Hyperliquid xyz dex) ===")
|
||||
t.Log("=== TSLA daily data (Hyperliquid xyz dex) ===")
|
||||
for i, c := range candles {
|
||||
openTime := time.UnixMilli(c.OpenTime).Format("2006-01-02 15:04:05")
|
||||
t.Logf("\n[%d] 时间: %s", i, openTime)
|
||||
t.Logf("\n[%d] Time: %s", i, openTime)
|
||||
t.Logf(" Symbol: %s", c.Symbol)
|
||||
t.Logf(" Interval: %s", c.Interval)
|
||||
t.Logf(" Open: %s", c.Open)
|
||||
@@ -58,33 +58,33 @@ func TestGetCandles_TSLA(t *testing.T) {
|
||||
t.Logf(" TradeCount: %d", c.TradeCount)
|
||||
}
|
||||
|
||||
// 打印原始 JSON
|
||||
// Print raw JSON
|
||||
res, _ := json.MarshalIndent(candles, "", " ")
|
||||
fmt.Printf("\n原始 JSON:\n%s\n", res)
|
||||
fmt.Printf("\nRaw JSON:\n%s\n", res)
|
||||
}
|
||||
|
||||
func TestGetCandles_StockPerps(t *testing.T) {
|
||||
client := NewClient()
|
||||
|
||||
// 测试多个股票永续合约 (xyz dex)
|
||||
// Test multiple stock perpetual contracts (xyz dex)
|
||||
symbols := []string{"TSLA", "NVDA", "AAPL", "MSFT"}
|
||||
|
||||
for _, symbol := range symbols {
|
||||
t.Logf("\n=== %s 日线数据 ===", symbol)
|
||||
t.Logf("\n=== %s daily data ===", symbol)
|
||||
candles, err := client.GetCandles(context.TODO(), symbol, "1d", 3)
|
||||
if err != nil {
|
||||
t.Errorf("%s 获取失败: %v", symbol, err)
|
||||
t.Errorf("%s fetch failed: %v", symbol, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(candles) == 0 {
|
||||
t.Logf("%s: 无数据", symbol)
|
||||
t.Logf("%s: no data", symbol)
|
||||
continue
|
||||
}
|
||||
|
||||
latest := candles[len(candles)-1]
|
||||
openTime := time.UnixMilli(latest.OpenTime).Format("2006-01-02")
|
||||
t.Logf("%s 最新: %s Open=%s High=%s Low=%s Close=%s Vol=%s",
|
||||
t.Logf("%s latest: %s Open=%s High=%s Low=%s Close=%s Vol=%s",
|
||||
symbol, openTime, latest.Open, latest.High, latest.Low, latest.Close, latest.Volume)
|
||||
}
|
||||
}
|
||||
@@ -97,19 +97,19 @@ func TestGetAllMids(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Log("=== 加密货币资产中间价 (默认 dex) ===")
|
||||
t.Log("=== Crypto asset mid prices (default dex) ===")
|
||||
|
||||
// 显示一些主要加密货币资产
|
||||
// Show some major crypto assets
|
||||
cryptoAssets := []string{"BTC", "ETH", "SOL", "DOGE", "XRP"}
|
||||
for _, asset := range cryptoAssets {
|
||||
if mid, ok := mids[asset]; ok {
|
||||
t.Logf("%s: %s", asset, mid)
|
||||
} else {
|
||||
t.Logf("%s: 不存在", asset)
|
||||
t.Logf("%s: not found", asset)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("\n总共 %d 个加密货币交易对", len(mids))
|
||||
t.Logf("\nTotal %d crypto trading pairs", len(mids))
|
||||
}
|
||||
|
||||
func TestGetAllMidsXYZ(t *testing.T) {
|
||||
@@ -120,14 +120,14 @@ func TestGetAllMidsXYZ(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Log("=== xyz dex 资产中间价 (股票、外汇、大宗商品) ===")
|
||||
t.Log("=== xyz dex asset mid prices (stocks, forex, commodities) ===")
|
||||
|
||||
// 显示所有 xyz dex 资产
|
||||
// Show all xyz dex assets
|
||||
for symbol, mid := range mids {
|
||||
t.Logf("%s: %s", symbol, mid)
|
||||
}
|
||||
|
||||
t.Logf("\n总共 %d 个 xyz dex 交易对", len(mids))
|
||||
t.Logf("\nTotal %d xyz dex trading pairs", len(mids))
|
||||
}
|
||||
|
||||
func TestGetMeta(t *testing.T) {
|
||||
@@ -138,11 +138,11 @@ func TestGetMeta(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Log("=== 资产元数据 ===")
|
||||
t.Logf("总共 %d 个资产", len(meta.Universe))
|
||||
t.Log("=== Asset metadata ===")
|
||||
t.Logf("Total %d assets", len(meta.Universe))
|
||||
|
||||
// 显示股票永续合约
|
||||
t.Log("\n股票永续合约:")
|
||||
// Show stock perpetual contracts
|
||||
t.Log("\nStock perpetual contracts:")
|
||||
for _, asset := range meta.Universe {
|
||||
if IsStockPerp(asset.Name) {
|
||||
t.Logf(" %s: szDecimals=%d, maxLeverage=%d", asset.Name, asset.SzDecimals, asset.MaxLeverage)
|
||||
|
||||
@@ -12,7 +12,7 @@ type QuantData struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Price float64 `json:"price"`
|
||||
Netflow *NetflowData `json:"netflow,omitempty"`
|
||||
OI map[string]*OIData `json:"oi,omitempty"` // keyed by exchange: "binance", "bybit"
|
||||
OI map[string]*OIData `json:"oi,omitempty"` // keyed by exchange: "binance", "bybit"
|
||||
PriceChange map[string]float64 `json:"price_change,omitempty"` // keyed by duration: "1h", "4h", etc.
|
||||
}
|
||||
|
||||
@@ -118,11 +118,11 @@ func FormatQuantDataForAI(symbol string, data *QuantData, lang Language) string
|
||||
func formatQuantDataZH(symbol string, data *QuantData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("### %s 量化数据\n", symbol))
|
||||
sb.WriteString(fmt.Sprintf("价格: $%.4f\n\n", data.Price))
|
||||
sb.WriteString(fmt.Sprintf("### %s Quant Data\n", symbol))
|
||||
sb.WriteString(fmt.Sprintf("Price: $%.4f\n\n", data.Price))
|
||||
|
||||
if len(data.PriceChange) > 0 {
|
||||
sb.WriteString("**价格变化**:\n")
|
||||
sb.WriteString("**Price Change**:\n")
|
||||
durations := []string{"1h", "4h", "8h", "12h", "24h"}
|
||||
for _, d := range durations {
|
||||
if change, ok := data.PriceChange[d]; ok {
|
||||
@@ -135,14 +135,14 @@ func formatQuantDataZH(symbol string, data *QuantData) string {
|
||||
if len(data.OI) > 0 {
|
||||
for exchange, oiData := range data.OI {
|
||||
if oiData != nil {
|
||||
sb.WriteString(fmt.Sprintf("**%s持仓**:\n", strings.ToUpper(exchange)))
|
||||
sb.WriteString(fmt.Sprintf("**%s Open Interest**:\n", strings.ToUpper(exchange)))
|
||||
sb.WriteString(fmt.Sprintf("- OI: %.2f\n", oiData.CurrentOI))
|
||||
if oiData.NetLong > 0 || oiData.NetShort > 0 {
|
||||
sb.WriteString(fmt.Sprintf("- 多头: %.2f, 空头: %.2f\n", oiData.NetLong, oiData.NetShort))
|
||||
sb.WriteString(fmt.Sprintf("- Long: %.2f, Short: %.2f\n", oiData.NetLong, oiData.NetShort))
|
||||
}
|
||||
if oiData.Delta != nil {
|
||||
if delta, ok := oiData.Delta["1h"]; ok && delta != nil {
|
||||
sb.WriteString(fmt.Sprintf("- 1h变化: %s (%.2f%%)\n",
|
||||
sb.WriteString(fmt.Sprintf("- 1h Change: %s (%.2f%%)\n",
|
||||
formatValue(delta.OIDeltaValue), delta.OIDeltaPercent))
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func formatQuantDataZH(symbol string, data *QuantData) string {
|
||||
}
|
||||
|
||||
if data.Netflow != nil && data.Netflow.Institution != nil && data.Netflow.Institution.Future != nil {
|
||||
sb.WriteString("**机构资金流**:\n")
|
||||
sb.WriteString("**Institution Net Flow**:\n")
|
||||
durations := []string{"1h", "4h", "24h"}
|
||||
for _, d := range durations {
|
||||
if flow, ok := data.Netflow.Institution.Future[d]; ok {
|
||||
|
||||
@@ -22,8 +22,8 @@ type NetFlowResponse struct {
|
||||
Data struct {
|
||||
Netflows []NetFlowPosition `json:"netflows"`
|
||||
Count int `json:"count"`
|
||||
Type string `json:"type"` // institution or personal
|
||||
Trade string `json:"trade"` // futures or spot
|
||||
Type string `json:"type"` // institution or personal
|
||||
Trade string `json:"trade"` // futures or spot
|
||||
TimeRange string `json:"time_range"`
|
||||
RankType string `json:"rank_type"` // top or low
|
||||
Limit int `json:"limit"`
|
||||
@@ -131,13 +131,13 @@ func FormatNetFlowRankingForAI(data *NetFlowRankingData, lang Language) string {
|
||||
func formatNetFlowRankingZH(data *NetFlowRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## 资金流向排行 (%s)\n\n", data.Duration))
|
||||
sb.WriteString(fmt.Sprintf("## Net Flow Ranking (%s)\n\n", data.Duration))
|
||||
|
||||
// Institution inflow
|
||||
if len(data.InstitutionFutureTop) > 0 {
|
||||
sb.WriteString("### 机构资金流入榜\n")
|
||||
sb.WriteString("Smart Money买入信号:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 流入金额(USDT) | 价格 |\n")
|
||||
sb.WriteString("### Institution Inflow Ranking\n")
|
||||
sb.WriteString("Smart Money buy signal:\n\n")
|
||||
sb.WriteString("| Rank | Symbol | Inflow Amount(USDT) | Price |\n")
|
||||
sb.WriteString("|------|------|----------------|------|\n")
|
||||
for _, pos := range data.InstitutionFutureTop {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n",
|
||||
@@ -148,9 +148,9 @@ func formatNetFlowRankingZH(data *NetFlowRankingData) string {
|
||||
|
||||
// Institution outflow
|
||||
if len(data.InstitutionFutureLow) > 0 {
|
||||
sb.WriteString("### 机构资金流出榜\n")
|
||||
sb.WriteString("Smart Money卖出信号:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 流出金额(USDT) | 价格 |\n")
|
||||
sb.WriteString("### Institution Outflow Ranking\n")
|
||||
sb.WriteString("Smart Money sell signal:\n\n")
|
||||
sb.WriteString("| Rank | Symbol | Outflow Amount(USDT) | Price |\n")
|
||||
sb.WriteString("|------|------|----------------|------|\n")
|
||||
for _, pos := range data.InstitutionFutureLow {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | $%.4f |\n",
|
||||
@@ -161,9 +161,9 @@ func formatNetFlowRankingZH(data *NetFlowRankingData) string {
|
||||
|
||||
// Retail flow summary
|
||||
if len(data.PersonalFutureTop) > 0 || len(data.PersonalFutureLow) > 0 {
|
||||
sb.WriteString("### 散户资金动向\n")
|
||||
sb.WriteString("### Retail Capital Movement\n")
|
||||
if len(data.PersonalFutureTop) > 0 {
|
||||
sb.WriteString("散户买入: ")
|
||||
sb.WriteString("Retail buy: ")
|
||||
for i, pos := range data.PersonalFutureTop {
|
||||
if i >= 3 {
|
||||
break
|
||||
@@ -176,7 +176,7 @@ func formatNetFlowRankingZH(data *NetFlowRankingData) string {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if len(data.PersonalFutureLow) > 0 {
|
||||
sb.WriteString("散户卖出: ")
|
||||
sb.WriteString("Retail sell: ")
|
||||
for i, pos := range data.PersonalFutureLow {
|
||||
if i >= 3 {
|
||||
break
|
||||
@@ -191,7 +191,7 @@ func formatNetFlowRankingZH(data *NetFlowRankingData) string {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("**解读**: 机构买入+散户卖出=强烈看多 | 机构卖出+散户买入=强烈看空\n\n")
|
||||
sb.WriteString("**Interpretation**: Institution buy + Retail sell = strongly bullish | Institution sell + Retail buy = strongly bearish\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -169,12 +169,12 @@ func FormatOIRankingForAI(data *OIRankingData, lang Language) string {
|
||||
func formatOIRankingZH(data *OIRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("## 持仓量变化排行 (%s)\n\n", data.Duration))
|
||||
sb.WriteString(fmt.Sprintf("## Open Interest Change Ranking (%s)\n\n", data.Duration))
|
||||
|
||||
if len(data.TopPositions) > 0 {
|
||||
sb.WriteString("### 持仓增加榜\n")
|
||||
sb.WriteString("资金流入,趋势延续或新仓建立信号:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 持仓变化(USDT) | OI变化% | 价格变化% |\n")
|
||||
sb.WriteString("### OI Increase Ranking\n")
|
||||
sb.WriteString("Capital inflow, trend continuation or new position signal:\n\n")
|
||||
sb.WriteString("| Rank | Symbol | OI Change(USDT) | OI Change% | Price Change% |\n")
|
||||
sb.WriteString("|------|------|----------------|---------|----------|\n")
|
||||
for _, pos := range data.TopPositions {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n",
|
||||
@@ -185,9 +185,9 @@ func formatOIRankingZH(data *OIRankingData) string {
|
||||
}
|
||||
|
||||
if len(data.LowPositions) > 0 {
|
||||
sb.WriteString("### 持仓减少榜\n")
|
||||
sb.WriteString("资金流出,趋势反转或仓位平仓信号:\n\n")
|
||||
sb.WriteString("| 排名 | 币种 | 持仓变化(USDT) | OI变化% | 价格变化% |\n")
|
||||
sb.WriteString("### OI Decrease Ranking\n")
|
||||
sb.WriteString("Capital outflow, trend reversal or position close signal:\n\n")
|
||||
sb.WriteString("| Rank | Symbol | OI Change(USDT) | OI Change% | Price Change% |\n")
|
||||
sb.WriteString("|------|------|----------------|---------|----------|\n")
|
||||
for _, pos := range data.LowPositions {
|
||||
sb.WriteString(fmt.Sprintf("| %d | %s | %s | %+.2f%% | %+.2f%% |\n",
|
||||
@@ -197,7 +197,7 @@ func formatOIRankingZH(data *OIRankingData) string {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("**解读**: OI增+价涨=多头主导 | OI增+价跌=空头主导 | OI减+价涨=空头平仓 | OI减+价跌=多头平仓\n\n")
|
||||
sb.WriteString("**Interpretation**: OI up + Price up = longs dominant | OI up + Price down = shorts dominant | OI down + Price up = shorts closing | OI down + Price down = longs closing\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
type PriceRankingItem struct {
|
||||
Pair string `json:"pair"`
|
||||
Symbol string `json:"symbol"`
|
||||
PriceDelta float64 `json:"price_delta"` // Decimal format: 0.0723 = 7.23%
|
||||
PriceDelta float64 `json:"price_delta"` // Decimal format: 0.0723 = 7.23%
|
||||
Price float64 `json:"price"`
|
||||
FutureFlow float64 `json:"future_flow"`
|
||||
SpotFlow float64 `json:"spot_flow"`
|
||||
@@ -98,7 +98,7 @@ func FormatPriceRankingForAI(data *PriceRankingData, lang Language) string {
|
||||
func formatPriceRankingZH(data *PriceRankingData) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## 涨跌幅排行\n\n")
|
||||
sb.WriteString("## Price Change Ranking\n\n")
|
||||
|
||||
durationOrder := []string{"1h", "4h", "24h"}
|
||||
for _, duration := range durationOrder {
|
||||
@@ -107,11 +107,11 @@ func formatPriceRankingZH(data *PriceRankingData) string {
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("### %s 涨跌幅\n\n", duration))
|
||||
sb.WriteString(fmt.Sprintf("### %s Price Change\n\n", duration))
|
||||
|
||||
if len(durationData.Top) > 0 {
|
||||
sb.WriteString("**涨幅榜**\n")
|
||||
sb.WriteString("| 币种 | 涨幅 | 价格 | 资金流 | OI变化 |\n")
|
||||
sb.WriteString("**Gainers**\n")
|
||||
sb.WriteString("| Symbol | Gain | Price | Net Flow | OI Change |\n")
|
||||
sb.WriteString("|------|------|------|--------|--------|\n")
|
||||
for _, item := range durationData.Top {
|
||||
sb.WriteString(fmt.Sprintf("| %s | %+.2f%% | $%.4f | %s | %s |\n",
|
||||
@@ -122,8 +122,8 @@ func formatPriceRankingZH(data *PriceRankingData) string {
|
||||
}
|
||||
|
||||
if len(durationData.Low) > 0 {
|
||||
sb.WriteString("**跌幅榜**\n")
|
||||
sb.WriteString("| 币种 | 跌幅 | 价格 | 资金流 | OI变化 |\n")
|
||||
sb.WriteString("**Losers**\n")
|
||||
sb.WriteString("| Symbol | Loss | Price | Net Flow | OI Change |\n")
|
||||
sb.WriteString("|------|------|------|--------|--------|\n")
|
||||
for _, item := range durationData.Low {
|
||||
sb.WriteString(fmt.Sprintf("| %s | %.2f%% | $%.4f | %s | %s |\n",
|
||||
@@ -134,7 +134,7 @@ func formatPriceRankingZH(data *PriceRankingData) string {
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("**解读**: 涨幅大+资金流入+OI增加=强势上涨 | 跌幅大+资金流出+OI减少=弱势下跌\n\n")
|
||||
sb.WriteString("**Interpretation**: Large gain + capital inflow + OI increase = strong uptrend | Large loss + capital outflow + OI decrease = weak downtrend\n\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const (
|
||||
SignalRankingPath = "/api/v1/vergex/signal-ranking"
|
||||
SignalLabPath = "/api/v1/vergex/signal-lab"
|
||||
CostLiquidationHeatmapPath = "/api/v1/vergex/cost-liquidation-heatmap"
|
||||
FlowMarketsPath = "/api/v1/vergex/flow-markets"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
@@ -128,6 +129,24 @@ func (c *Client) GetCostLiquidationHeatmap(ctx context.Context, q Query) (json.R
|
||||
return c.doGET(ctx, CostLiquidationHeatmapPath, params)
|
||||
}
|
||||
|
||||
// GetFlowMarkets fetches the Vergex net-flow market ranking via the paid
|
||||
// claw402 x402 endpoint. Params mirror the public API: chain (e.g. "mainnet"),
|
||||
// window (e.g. "1h"), and limit. The raw JSON is returned for the caller to
|
||||
// pass through — the response shape is owned by Vergex.
|
||||
func (c *Client) GetFlowMarkets(ctx context.Context, chain, window string, limit int) (json.RawMessage, error) {
|
||||
params := url.Values{}
|
||||
if v := strings.TrimSpace(chain); v != "" {
|
||||
params.Set("chain", v)
|
||||
}
|
||||
if v := strings.TrimSpace(window); v != "" {
|
||||
params.Set("window", v)
|
||||
}
|
||||
if limit > 0 {
|
||||
params.Set("limit", fmt.Sprintf("%d", limit))
|
||||
}
|
||||
return c.doGET(ctx, FlowMarketsPath, params)
|
||||
}
|
||||
|
||||
func addQueryDefaults(params url.Values, q Query, includeMarket bool) {
|
||||
if includeMarket {
|
||||
if q.MarketType != "" {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -347,6 +347,27 @@ func (s *PositionStore) GetOpenPositions(traderID string) ([]*TraderPosition, er
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
// GetOpenPositionsByExchange returns every OPEN row on an exchange account,
|
||||
// across all trader IDs. An exchange account is shared by every "NOFX
|
||||
// Autopilot" relaunch (each relaunch mints a fresh trader_id), so reconciling
|
||||
// must be scoped to the exchange — not the current trader_id — or rows left by
|
||||
// prior incarnations become permanent orphans that never close.
|
||||
func (s *PositionStore) GetOpenPositionsByExchange(exchangeID string) ([]*TraderPosition, error) {
|
||||
var positions []*TraderPosition
|
||||
err := s.db.Where("exchange_id = ? AND status = ?", exchangeID, "OPEN").
|
||||
Order("entry_time DESC").
|
||||
Find(&positions).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query open positions by exchange: %w", err)
|
||||
}
|
||||
for _, pos := range positions {
|
||||
if pos.EntryQuantity == 0 {
|
||||
pos.EntryQuantity = pos.Quantity
|
||||
}
|
||||
}
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
// GetOpenPositionBySymbol gets open position for specified symbol and direction
|
||||
func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (*TraderPosition, error) {
|
||||
var pos TraderPosition
|
||||
@@ -382,11 +403,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)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,9 @@ type HistorySummary struct {
|
||||
func (s *PositionStore) GetHistorySummary(traderID string) (*HistorySummary, error) {
|
||||
summary := &HistorySummary{}
|
||||
|
||||
fullStats, err := s.GetFullStats(traderID)
|
||||
// Baseline 0 is fine here: the summary only reads counts/win-rate/PnL, not
|
||||
// the drawdown that depends on it.
|
||||
fullStats, err := s.GetFullStats(traderID, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -54,20 +54,20 @@ func (s *PositionStore) GetPositionStats(traderID string) (map[string]interface{
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetFullStats gets complete trading statistics
|
||||
func (s *PositionStore) GetFullStats(traderID string) (*TraderStats, error) {
|
||||
// GetFullStats gets complete trading statistics. startingEquity is the real
|
||||
// account baseline used for the drawdown equity curve; pass 0 when unknown.
|
||||
func (s *PositionStore) GetFullStats(traderID string, startingEquity float64) (*TraderStats, error) {
|
||||
return s.GetFullStatsByTraderFilters([]string{traderID}, nil, startingEquity)
|
||||
}
|
||||
|
||||
// GetFullStatsByTraderFilters gets complete trading statistics for explicit
|
||||
// trader IDs plus optional legacy trader ID patterns. startingEquity is the
|
||||
// real account baseline for the drawdown calculation; pass 0 when unknown.
|
||||
func (s *PositionStore) GetFullStatsByTraderFilters(traderIDs []string, traderIDPatterns []string, startingEquity float64) (*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 {
|
||||
@@ -108,7 +108,7 @@ func (s *PositionStore) GetFullStats(traderID string) (*TraderStats, error) {
|
||||
stats.SharpeRatio = calculateSharpeRatioFromPnls(pnls)
|
||||
}
|
||||
if len(pnls) > 0 {
|
||||
stats.MaxDrawdownPct = calculateMaxDrawdownFromPnls(pnls)
|
||||
stats.MaxDrawdownPct = calculateMaxDrawdownFromPnls(pnls, startingEquity)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
@@ -194,13 +194,21 @@ func calculateSharpeRatioFromPnls(pnls []float64) float64 {
|
||||
return mean / stdDev
|
||||
}
|
||||
|
||||
// calculateMaxDrawdownFromPnls calculates maximum drawdown
|
||||
func calculateMaxDrawdownFromPnls(pnls []float64) float64 {
|
||||
// calculateMaxDrawdownFromPnls reconstructs an equity curve from the realized
|
||||
// PnL sequence on top of startingEquity and returns the max peak-to-trough
|
||||
// drawdown as a PERCENT (e.g. 18.5 = -18.5%). The baseline matters: the same
|
||||
// $87 dip is 0.9% of a $10k account but 18% of a $480 one, so callers should
|
||||
// pass the trader's real initial balance. A non-positive baseline falls back
|
||||
// to a neutral $10k so the metric stays defined (but understated) when the
|
||||
// account baseline is unknown.
|
||||
func calculateMaxDrawdownFromPnls(pnls []float64, startingEquity float64) float64 {
|
||||
if len(pnls) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
const startingEquity = 10000.0
|
||||
if startingEquity <= 0 {
|
||||
startingEquity = 10000.0
|
||||
}
|
||||
equity := startingEquity
|
||||
peak := startingEquity
|
||||
var maxDD float64
|
||||
@@ -234,8 +242,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 +325,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 +388,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)
|
||||
}
|
||||
|
||||
111
store/position_reconcile.go
Normal file
111
store/position_reconcile.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nofx/logger"
|
||||
)
|
||||
|
||||
const reconcileQuantityTolerance = 0.0001
|
||||
|
||||
// LivePositionKey builds the map key used by ReconcileOpenPositionsWithLive.
|
||||
func LivePositionKey(symbol, side string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(symbol)) + "|" + strings.ToUpper(strings.TrimSpace(side))
|
||||
}
|
||||
|
||||
// ReconcileOpenPositionsWithLive force-closes local OPEN position rows that the
|
||||
// exchange no longer holds, and trims rows whose quantity exceeds what is live.
|
||||
//
|
||||
// Why this exists: missed or unmatched fills (position flips, liquidations,
|
||||
// sync gaps) leave "zombie" OPEN rows behind. Every later close on the same
|
||||
// symbol+side then lands as a partial close against the zombie, so the row
|
||||
// never reaches CLOSED and its realized PnL never enters the closed-trade
|
||||
// statistics — the dashboard, the Edge Profile and the AI's own track-record
|
||||
// context all silently under-report. Reconciling against the exchange's live
|
||||
// book is the self-healing fix: local OPEN rows must always be a subset of
|
||||
// what the exchange actually holds.
|
||||
//
|
||||
// liveQty maps LivePositionKey(symbol, side) → live quantity on the exchange.
|
||||
// Rows are matched newest-first so the freshest row survives as the live
|
||||
// position's bookkeeping and older duplicates get closed.
|
||||
//
|
||||
// Scope is by exchange account (all trader IDs), so rows left by prior
|
||||
// autopilot incarnations on the same exchange are reconciled too.
|
||||
func (s *PositionStore) ReconcileOpenPositionsWithLive(exchangeID string, liveQty map[string]float64) (int, error) {
|
||||
openRows, err := s.GetOpenPositionsByExchange(exchangeID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to list open positions: %w", err)
|
||||
}
|
||||
if len(openRows) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Copy so we can consume quantities without mutating the caller's map.
|
||||
remaining := make(map[string]float64, len(liveQty))
|
||||
for k, v := range liveQty {
|
||||
remaining[strings.ToUpper(k)] = v
|
||||
}
|
||||
|
||||
// Newest first: the most recent row keeps representing the live position.
|
||||
sort.Slice(openRows, func(i, j int) bool {
|
||||
return openRows[i].EntryTime > openRows[j].EntryTime
|
||||
})
|
||||
|
||||
nowMs := time.Now().UTC().UnixMilli()
|
||||
closed := 0
|
||||
|
||||
for _, row := range openRows {
|
||||
key := LivePositionKey(row.Symbol, row.Side)
|
||||
live := remaining[key]
|
||||
|
||||
if live > reconcileQuantityTolerance {
|
||||
// The exchange still holds (part of) this key — this row survives.
|
||||
if row.Quantity > live+reconcileQuantityTolerance {
|
||||
// Trim the row down to what is actually live so the next real
|
||||
// close matches sizes and can fully close it. No PnL is
|
||||
// fabricated: the trimmed residue is stale bookkeeping, not a
|
||||
// real fill.
|
||||
trim := row.Quantity - live
|
||||
exitPrice := row.ExitPrice
|
||||
if exitPrice <= 0 {
|
||||
exitPrice = row.EntryPrice
|
||||
}
|
||||
if err := s.ReducePositionQuantity(row.ID, trim, exitPrice, 0, 0); err != nil {
|
||||
logger.Infof(" ⚠️ Reconcile: failed to trim position %d (%s %s): %v", row.ID, row.Symbol, row.Side, err)
|
||||
} else {
|
||||
logger.Infof(" 🧹 Reconcile: trimmed %s %s row %d by %.6f to match live %.6f", row.Symbol, row.Side, row.ID, trim, live)
|
||||
}
|
||||
remaining[key] = 0
|
||||
} else {
|
||||
remaining[key] = live - row.Quantity
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Nothing (left) on the exchange for this key — the row is a zombie.
|
||||
// Close it with whatever it accumulated; exit info falls back to the
|
||||
// last known bookkeeping on the row.
|
||||
exitPrice := row.ExitPrice
|
||||
if exitPrice <= 0 {
|
||||
exitPrice = row.EntryPrice
|
||||
}
|
||||
exitTime := row.UpdatedAt
|
||||
if exitTime <= 0 {
|
||||
exitTime = nowMs
|
||||
}
|
||||
if err := s.ClosePositionFully(row.ID, exitPrice, row.ExitOrderID, exitTime, row.RealizedPnL, row.Fee, "reconcile"); err != nil {
|
||||
logger.Infof(" ⚠️ Reconcile: failed to close zombie position %d (%s %s): %v", row.ID, row.Symbol, row.Side, err)
|
||||
continue
|
||||
}
|
||||
closed++
|
||||
logger.Infof(" 🧹 Reconcile: closed zombie %s %s row %d (qty %.6f, accumulated PnL %.2f) — not held on exchange", row.Symbol, row.Side, row.ID, row.Quantity, row.RealizedPnL)
|
||||
}
|
||||
|
||||
if closed > 0 {
|
||||
logger.Infof("✅ Position reconcile: closed %d zombie row(s) on exchange %s", closed, exchangeID)
|
||||
}
|
||||
return closed, nil
|
||||
}
|
||||
113
store/position_reconcile_test.go
Normal file
113
store/position_reconcile_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newReconcileTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
st, err := New(t.TempDir() + "/nofx.db")
|
||||
if err != nil {
|
||||
t.Fatalf("store.New failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func openRow(t *testing.T, st *Store, traderID, exchangeID, symbol, side string, qty, pnl float64, entryMs int64) int64 {
|
||||
t.Helper()
|
||||
pos := &TraderPosition{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
ExchangeType: "hyperliquid",
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Quantity: qty,
|
||||
EntryQuantity: qty,
|
||||
EntryPrice: 100,
|
||||
EntryTime: entryMs,
|
||||
RealizedPnL: pnl,
|
||||
Status: "OPEN",
|
||||
Source: "sync",
|
||||
CreatedAt: entryMs,
|
||||
UpdatedAt: entryMs,
|
||||
}
|
||||
if err := st.Position().CreateOpenPosition(pos); err != nil {
|
||||
t.Fatalf("create open position: %v", err)
|
||||
}
|
||||
return pos.ID
|
||||
}
|
||||
|
||||
func TestReconcileClosesZombiesKeepsLiveAndTrims(t *testing.T) {
|
||||
st := newReconcileTestStore(t)
|
||||
const exch = "ex-hl"
|
||||
base := time.Now().Add(-48 * time.Hour).UnixMilli()
|
||||
|
||||
// Zombie under the CURRENT trader id: exchange holds nothing for DRAM.
|
||||
zombieID := openRow(t, st, "trader-now", exch, "xyz:DRAM", "LONG", 6.8, -20.34, base)
|
||||
|
||||
// Zombie left by a PRIOR autopilot incarnation on the SAME exchange —
|
||||
// this is the case a per-trader-id reconcile would miss.
|
||||
legacyID := openRow(t, st, "trader-old", exch, "SOLUSDT", "SHORT", 6.94, -3.5, base+500)
|
||||
|
||||
// Duplicates for SP500 (different incarnations): newest survives trimmed,
|
||||
// older closes.
|
||||
oldSP := openRow(t, st, "trader-old", exch, "xyz:SP500", "LONG", 0.07, -1.5, base+1000)
|
||||
newSP := openRow(t, st, "trader-now", exch, "xyz:SP500", "LONG", 0.124, 2.5, base+2000)
|
||||
|
||||
// Healthy row exactly matching live — untouched.
|
||||
healthy := openRow(t, st, "trader-now", exch, "BTCUSDT", "LONG", 0.01, 0, base+3000)
|
||||
|
||||
// Row on a DIFFERENT exchange account — must be out of scope.
|
||||
otherExch := openRow(t, st, "trader-now", "ex-other", "ETHUSDT", "LONG", 2.0, 0, base+4000)
|
||||
|
||||
live := map[string]float64{
|
||||
LivePositionKey("xyz:SP500", "long"): 0.057,
|
||||
LivePositionKey("BTCUSDT", "long"): 0.01,
|
||||
}
|
||||
|
||||
closed, err := st.Position().ReconcileOpenPositionsWithLive(exch, live)
|
||||
if err != nil {
|
||||
t.Fatalf("reconcile failed: %v", err)
|
||||
}
|
||||
if closed != 3 {
|
||||
t.Fatalf("expected 3 zombies closed (DRAM + legacy SOL + old SP500), got %d", closed)
|
||||
}
|
||||
|
||||
get := func(id int64) *TraderPosition {
|
||||
t.Helper()
|
||||
var pos TraderPosition
|
||||
if err := st.Position().db.First(&pos, id).Error; err != nil {
|
||||
t.Fatalf("load position %d: %v", id, err)
|
||||
}
|
||||
return &pos
|
||||
}
|
||||
|
||||
if dram := get(zombieID); dram.Status != "CLOSED" || dram.RealizedPnL != -20.34 || dram.CloseReason != "reconcile" {
|
||||
t.Fatalf("DRAM zombie should close via reconcile keeping PnL, got %+v", dram)
|
||||
}
|
||||
if sol := get(legacyID); sol.Status != "CLOSED" {
|
||||
t.Fatalf("legacy-incarnation SOL zombie on same exchange should close, got %+v", sol)
|
||||
}
|
||||
if sp := get(oldSP); sp.Status != "CLOSED" {
|
||||
t.Fatalf("older duplicate SP500 row should close, got %+v", sp)
|
||||
}
|
||||
if sp := get(newSP); sp.Status != "OPEN" || sp.Quantity > 0.0571 || sp.Quantity < 0.0569 {
|
||||
t.Fatalf("newest SP500 row should stay open trimmed to live 0.057, got status=%s qty=%v", sp.Status, sp.Quantity)
|
||||
}
|
||||
if btc := get(healthy); btc.Status != "OPEN" || btc.Quantity != 0.01 {
|
||||
t.Fatalf("healthy row must be untouched, got %+v", btc)
|
||||
}
|
||||
if eth := get(otherExch); eth.Status != "OPEN" {
|
||||
t.Fatalf("row on a different exchange must be out of scope, got %+v", eth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileNoOpenRowsIsNoop(t *testing.T) {
|
||||
st := newReconcileTestStore(t)
|
||||
closed, err := st.Position().ReconcileOpenPositionsWithLive("ex-empty", map[string]float64{})
|
||||
if err != nil || closed != 0 {
|
||||
t.Fatalf("expected clean noop, got closed=%d err=%v", closed, err)
|
||||
}
|
||||
}
|
||||
@@ -42,3 +42,116 @@ func TestGetOpenPositionBySymbolMatchesSideCaseInsensitively(t *testing.T) {
|
||||
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_%"},
|
||||
0,
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateMaxDrawdownUsesRealBaseline(t *testing.T) {
|
||||
// +50 then -100: peak 550, trough 450 on a 500 account → 100/550 ≈ 18.18%.
|
||||
pnls := []float64{50, -100}
|
||||
|
||||
got := calculateMaxDrawdownFromPnls(pnls, 500)
|
||||
if got < 18.1 || got > 18.3 {
|
||||
t.Fatalf("expected ~18.18%% drawdown on a 500 baseline, got %.2f", got)
|
||||
}
|
||||
|
||||
// Unknown baseline falls back to the neutral 10k curve: 100/10050 ≈ 1%.
|
||||
fallback := calculateMaxDrawdownFromPnls(pnls, 0)
|
||||
if fallback < 0.9 || fallback > 1.1 {
|
||||
t.Fatalf("expected ~1%% drawdown on the 10k fallback, got %.2f", fallback)
|
||||
}
|
||||
|
||||
if calculateMaxDrawdownFromPnls(nil, 500) != 0 {
|
||||
t.Fatalf("no trades must mean zero drawdown")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// Hard limits to prevent token explosion in AI requests
|
||||
const (
|
||||
MaxCandidateCoins = 10
|
||||
MaxPositions = 3
|
||||
MaxPositions = 8
|
||||
MaxTimeframes = 4
|
||||
MinKlineCount = 10
|
||||
MaxKlineCount = 30
|
||||
@@ -268,9 +268,9 @@ func (c *StrategyConfig) NormalizeProductSchema() {
|
||||
func normalizeStrategyType(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
switch value {
|
||||
case "grid", "grid_strategy", "grid-trading", "grid trading", "grid_trading", "网格", "网格策略", "网格交易":
|
||||
case "grid", "grid_strategy", "grid-trading", "grid trading", "grid_trading", "grid strategy":
|
||||
return "grid_trading"
|
||||
case "", "ai", "ai_strategy", "ai-trading", "ai trading", "ai_trading", "ai策略", "ai 策略", "ai交易策略", "ai智能策略":
|
||||
case "", "ai", "ai_strategy", "ai-trading", "ai trading", "ai_trading", "ai strategy", "ai smart strategy":
|
||||
return "ai_trading"
|
||||
default:
|
||||
return value
|
||||
@@ -279,25 +279,25 @@ func normalizeStrategyType(value string) string {
|
||||
|
||||
func normalizeCoinSourceType(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
compact := strings.NewReplacer(" ", "", "_", "", "-", "", "数据源", "", "选币", "", "币种", "").Replace(value)
|
||||
compact := strings.NewReplacer(" ", "", "_", "", "-", "", "datasource", "", "coinselection", "", "coin", "").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, "持仓量靠前"):
|
||||
case strings.Contains(compact, "oitop") || strings.Contains(value, "oi top") || strings.Contains(value, "highest open interest") || strings.Contains(value, "top open interest"):
|
||||
return "oi_top"
|
||||
case strings.Contains(compact, "oilow") || strings.Contains(value, "oi low") || strings.Contains(value, "持仓量最低") || strings.Contains(value, "持仓量较低"):
|
||||
case strings.Contains(compact, "oilow") || strings.Contains(value, "oi low") || strings.Contains(value, "lowest open interest") || strings.Contains(value, "low open interest"):
|
||||
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, "信号榜"):
|
||||
case strings.Contains(compact, "vergex") || strings.Contains(compact, "claw402") || strings.Contains(compact, "dynamicranking") || strings.Contains(value, "dynamic board") || strings.Contains(value, "gainers board") || strings.Contains(value, "signal board"):
|
||||
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, "静态"):
|
||||
case strings.Contains(value, "static") || strings.Contains(value, "fixed"):
|
||||
return "static"
|
||||
default:
|
||||
return value
|
||||
@@ -395,25 +395,25 @@ func splitLooseStringList(values []string) []string {
|
||||
|
||||
func normalizeTimeframe(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
value = strings.Trim(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",
|
||||
"1 minute": "1m",
|
||||
"3 minute": "3m",
|
||||
"5 minute": "5m",
|
||||
"15 minute": "15m",
|
||||
"30 minute": "30m",
|
||||
"1 hour": "1h",
|
||||
"2 hour": "2h",
|
||||
"4 hour": "4h",
|
||||
"6 hour": "6h",
|
||||
"8 hour": "8h",
|
||||
"12 hour": "12h",
|
||||
"1 day": "1d",
|
||||
"3 day": "3d",
|
||||
"1 week": "1w",
|
||||
}
|
||||
if alias, ok := aliases[value]; ok {
|
||||
return alias
|
||||
@@ -559,7 +559,7 @@ func StrategyClampWarnings(before, after StrategyConfig, lang string) []string {
|
||||
return
|
||||
}
|
||||
if lang == "zh" {
|
||||
warnings = append(warnings, fmt.Sprintf("%s 已从 %d 调整为 %d", labelZH, from, to))
|
||||
warnings = append(warnings, fmt.Sprintf("%s adjusted from %d to %d", labelZH, from, to))
|
||||
return
|
||||
}
|
||||
warnings = append(warnings, fmt.Sprintf("%s adjusted from %d to %d", labelEN, from, to))
|
||||
@@ -569,21 +569,21 @@ func StrategyClampWarnings(before, after StrategyConfig, lang string) []string {
|
||||
return
|
||||
}
|
||||
if lang == "zh" {
|
||||
warnings = append(warnings, fmt.Sprintf("%s 已从 %.2f 调整为 %.2f", labelZH, from, to))
|
||||
warnings = append(warnings, fmt.Sprintf("%s adjusted from %.2f to %.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)
|
||||
appendInt("Max Positions", "max_positions", before.RiskControl.MaxPositions, after.RiskControl.MaxPositions)
|
||||
appendInt("BTC/ETH Max Leverage", "btc_eth_max_leverage", before.RiskControl.BTCETHMaxLeverage, after.RiskControl.BTCETHMaxLeverage)
|
||||
appendInt("Altcoin Max Leverage", "altcoin_max_leverage", before.RiskControl.AltcoinMaxLeverage, after.RiskControl.AltcoinMaxLeverage)
|
||||
appendFloat("BTC/ETH Max Position Value Ratio", "btc_eth_max_position_value_ratio", before.RiskControl.BTCETHMaxPositionValueRatio, after.RiskControl.BTCETHMaxPositionValueRatio)
|
||||
appendFloat("Altcoin Max Position Value Ratio", "altcoin_max_position_value_ratio", before.RiskControl.AltcoinMaxPositionValueRatio, after.RiskControl.AltcoinMaxPositionValueRatio)
|
||||
appendFloat("Min Risk/Reward Ratio", "min_risk_reward_ratio", before.RiskControl.MinRiskRewardRatio, after.RiskControl.MinRiskRewardRatio)
|
||||
appendFloat("Max Margin Usage", "max_margin_usage", before.RiskControl.MaxMarginUsage, after.RiskControl.MaxMarginUsage)
|
||||
appendFloat("Min Position Size", "min_position_size", before.RiskControl.MinPositionSize, after.RiskControl.MinPositionSize)
|
||||
appendInt("Min Confidence", "min_confidence", before.RiskControl.MinConfidence, after.RiskControl.MinConfidence)
|
||||
return warnings
|
||||
}
|
||||
|
||||
@@ -1014,37 +1014,37 @@ func GetDefaultStrategyConfig(lang string) StrategyConfig {
|
||||
PriceRankingLimit: 10,
|
||||
},
|
||||
RiskControl: RiskControlConfig{
|
||||
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)
|
||||
MaxPositions: 2, // Few, concentrated positions held for big moves (CODE ENFORCED)
|
||||
BTCETHMaxLeverage: 5, // Low leverage so a big (-5%) stop is survivable, not an instant liquidation
|
||||
AltcoinMaxLeverage: 5, // Low leverage so a big (-5%) stop is survivable, not an instant liquidation
|
||||
BTCETHMaxPositionValueRatio: 2.5, // Per-position notional = equity × 2.5; 2 positions = 5x total (full margin at 5x, ~20% liquidation cushion)
|
||||
AltcoinMaxPositionValueRatio: 2.5, // Per-position notional = equity × 2.5; 2 positions = 5x total (full margin at 5x, ~20% liquidation cushion)
|
||||
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: `# 你是 NOFX Claw402 自动交易员
|
||||
RoleDefinition: `# You are the NOFX Claw402 auto-trader
|
||||
|
||||
你只交易 Claw402.ai/Vergex 本轮榜单返回的 Hyperliquid 可交易标的。候选池来自 Claw402.ai/Vergex,开仓前必须结合 Signal Lab、成本/清算热力图和原始 K 线判断。`,
|
||||
TradingFrequency: `# 交易频率
|
||||
Trade only the Hyperliquid tradable instruments returned by this cycle's Claw402.ai/Vergex board. The candidate pool comes from Claw402.ai/Vergex; before opening a position, you must combine Signal Lab, cost/liquidation heatmap and raw candles.`,
|
||||
TradingFrequency: `# Trading Frequency
|
||||
|
||||
- 优先等待高质量机会,不需要每轮都交易。
|
||||
- 先管理已有持仓,再考虑新开仓。
|
||||
- 同一轮不要频繁开平同一标的。`,
|
||||
EntryStandards: `# 入场标准
|
||||
- Prioritize waiting for high-quality opportunities; you do not need to trade every cycle.
|
||||
- Manage existing positions first, then consider opening new ones.
|
||||
- Do not churn in and out of the same symbol in one cycle.`,
|
||||
EntryStandards: `# Entry Standards
|
||||
|
||||
只有 Claw402 Signal Lab、成本/清算热力图和原始 K 线大体一致时才开仓。Claw402 排名只是候选池,不是单独买入理由。任一关键数据缺失或冲突时,默认等待。`,
|
||||
DecisionProcess: `# 决策流程
|
||||
Open a position only when Claw402 Signal Lab, cost/liquidation heatmap and raw candles broadly agree. The Claw402 ranking is only the candidate pool, not a standalone buy reason. Wait by default when any key data is missing or contradictory.`,
|
||||
DecisionProcess: `# Decision Process
|
||||
|
||||
1. 检查已有持仓,先决定止盈、止损或继续持有。
|
||||
2. 从 Claw402 榜单取本轮候选,并对每个候选读取 Claw402 Ranking、Signal Lab、Cost/Liquidation Heatmap。
|
||||
3. 用原始 K 线确认入场位置、止损和止盈。
|
||||
4. 输出简洁 reasoning 和严格 JSON。`,
|
||||
1. Check existing positions first: decide take profit, stop loss or hold.
|
||||
2. Pull this cycle's candidates from the Claw402 board, and for each candidate read Claw402 Ranking, Signal Lab and Cost/Liquidation Heatmap.
|
||||
3. Use raw candles to confirm entry, stop loss and take profit.
|
||||
4. Output concise reasoning and strict JSON.`,
|
||||
}
|
||||
} else {
|
||||
config.PromptSections = PromptSectionsConfig{
|
||||
|
||||
@@ -80,14 +80,14 @@ func TestStrategyConfigUnmarshalLegacyFlatAIConfig(t *testing.T) {
|
||||
func TestStrategyConfigNormalizeProductSchemaForLLMLabels(t *testing.T) {
|
||||
cfg := GetDefaultStrategyConfig("zh")
|
||||
patch := map[string]any{
|
||||
"strategy_type": "AI 策略",
|
||||
"strategy_type": "AI strategy",
|
||||
"ai_config": map[string]any{
|
||||
"coin_source": map[string]any{
|
||||
"source_type": "AI500",
|
||||
},
|
||||
"indicators": map[string]any{
|
||||
"klines": map[string]any{
|
||||
"primary_timeframe": "1分钟",
|
||||
"primary_timeframe": "1 minute",
|
||||
"selected_timeframes": []any{`["1m"`, `"5m"`, `"15m"]`},
|
||||
},
|
||||
},
|
||||
@@ -126,7 +126,7 @@ func TestStrategyConfigNormalizeProductSchemaForLLMLabels(t *testing.T) {
|
||||
func TestStrategyConfigNormalizeProductSchemaForVergexSignal(t *testing.T) {
|
||||
cfg := GetDefaultStrategyConfig("zh")
|
||||
cfg.CoinSource = CoinSourceConfig{
|
||||
SourceType: "Claw402 Vergex 信号榜",
|
||||
SourceType: "Claw402 Vergex signal board",
|
||||
}
|
||||
|
||||
cfg.NormalizeProductSchema()
|
||||
|
||||
@@ -273,7 +273,7 @@ func (a *Agent) Run(userMessage string, onChunk func(string)) string {
|
||||
|
||||
// Safety: max iterations reached.
|
||||
logger.Warnf("Agent: max iterations (%d) reached for message: %q", maxIterations, userMessage)
|
||||
reply := "Operation completed. Please check your account for the latest status. / 操作已完成,请检查您的账户查看最新状态。"
|
||||
reply := "Operation completed. Please check your account for the latest status."
|
||||
a.memory.Add("user", userMessage)
|
||||
a.memory.Add("assistant", reply)
|
||||
return reply
|
||||
|
||||
@@ -213,7 +213,7 @@ func TestNarrationStructurallyImpossible(t *testing.T) {
|
||||
|
||||
// Simulate a (malformed) response that has both Content and ToolCalls.
|
||||
malformed := &mcp.LLMResponse{
|
||||
Content: "现在我将为您查询策略。", // narration — must NOT reach user
|
||||
Content: "Now I will look up your strategies.", // narration — must NOT reach user
|
||||
ToolCalls: []mcp.ToolCall{{
|
||||
ID: "c1",
|
||||
Type: "function",
|
||||
@@ -226,15 +226,15 @@ func TestNarrationStructurallyImpossible(t *testing.T) {
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
malformed,
|
||||
textReply("你有1个策略:BTC Trend。"),
|
||||
textReply("You have 1 strategy: BTC Trend."),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
reply := a.Run("查询我的策略", nil)
|
||||
reply := a.Run("look up my strategies", nil)
|
||||
|
||||
if strings.Contains(reply, "现在我将") {
|
||||
if strings.Contains(reply, "Now I will") {
|
||||
t.Fatalf("narration leaked into final reply: %q", reply)
|
||||
}
|
||||
if reply != "你有1个策略:BTC Trend。" {
|
||||
if reply != "You have 1 strategy: BTC Trend." {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -276,18 +276,18 @@ func TestOnChunkCalledWithFinalReply(t *testing.T) {
|
||||
// Verifies: POST strategy → GET verify → final reply shows strategy info.
|
||||
func TestCreateStrategyWorkflow(t *testing.T) {
|
||||
srv, port := mockAPIServer(map[string]string{
|
||||
"POST /api/strategies": `{"id":"s1","name":"BTC趋势"}`,
|
||||
"GET /api/strategies/s1": `{"id":"s1","name":"BTC趋势","config":{"coin_source":{"source_type":"static","static_coins":["BTC/USDT"]},"leverage":5}}`,
|
||||
"POST /api/strategies": `{"id":"s1","name":"BTC Trend"}`,
|
||||
"GET /api/strategies/s1": `{"id":"s1","name":"BTC Trend","config":{"coin_source":{"source_type":"static","static_coins":["BTC/USDT"]},"leverage":5}}`,
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("c1", "POST", "/api/strategies", `{"name":"BTC趋势","config":{}}`),
|
||||
toolCall("c1", "POST", "/api/strategies", `{"name":"BTC Trend","config":{}}`),
|
||||
toolCall("c2", "GET", "/api/strategies/s1", "{}"),
|
||||
textReply("策略已创建:BTC趋势,币种 BTC/USDT,杠杆 5x。"),
|
||||
textReply("Strategy created: BTC Trend, coin BTC/USDT, leverage 5x."),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
reply := a.Run("帮我配置个btc趋势交易的策略", nil)
|
||||
reply := a.Run("set up a BTC trend trading strategy for me", nil)
|
||||
|
||||
if llm.calls != 3 {
|
||||
t.Fatalf("expected 3 LLM calls, got %d", llm.calls)
|
||||
@@ -298,7 +298,7 @@ func TestCreateStrategyWorkflow(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestFullSetupWorkflow: create strategy → verify → create trader → start trader.
|
||||
// This is the "帮我配置策略并跑起来" workflow.
|
||||
// This is the "create strategy and start it" workflow.
|
||||
func TestFullSetupWorkflow(t *testing.T) {
|
||||
calls := map[string]int{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -306,11 +306,11 @@ func TestFullSetupWorkflow(t *testing.T) {
|
||||
calls[key]++
|
||||
switch key {
|
||||
case "POST /api/strategies":
|
||||
w.Write([]byte(`{"id":"s1","name":"BTC趋势"}`)) //nolint:errcheck
|
||||
w.Write([]byte(`{"id":"s1","name":"BTC Trend"}`)) //nolint:errcheck
|
||||
case "GET /api/strategies/s1":
|
||||
w.Write([]byte(`{"id":"s1","name":"BTC趋势","config":{}}`)) //nolint:errcheck
|
||||
w.Write([]byte(`{"id":"s1","name":"BTC Trend","config":{}}`)) //nolint:errcheck
|
||||
case "POST /api/traders":
|
||||
w.Write([]byte(`{"id":"tr1","name":"BTC趋势交易员"}`)) //nolint:errcheck
|
||||
w.Write([]byte(`{"id":"tr1","name":"BTC Trend Trader"}`)) //nolint:errcheck
|
||||
case "POST /api/traders/tr1/start":
|
||||
w.Write([]byte(`{"ok":true}`)) //nolint:errcheck
|
||||
default:
|
||||
@@ -322,14 +322,14 @@ func TestFullSetupWorkflow(t *testing.T) {
|
||||
fmt.Sscanf(srv.Listener.Addr().String(), "127.0.0.1:%d", &port)
|
||||
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("c1", "POST", "/api/strategies", `{"name":"BTC趋势"}`),
|
||||
toolCall("c1", "POST", "/api/strategies", `{"name":"BTC Trend"}`),
|
||||
toolCall("c2", "GET", "/api/strategies/s1", "{}"),
|
||||
toolCall("c3", "POST", "/api/traders", `{"name":"BTC趋势交易员","strategy_id":"s1"}`),
|
||||
toolCall("c3", "POST", "/api/traders", `{"name":"BTC Trend Trader","strategy_id":"s1"}`),
|
||||
toolCall("c4", "POST", "/api/traders/tr1/start", "{}"),
|
||||
textReply("策略和交易员已创建并启动!BTC趋势交易员正在运行。"),
|
||||
textReply("Strategy and trader created and started! BTC Trend Trader is running."),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
reply := a.Run("帮我配置个btc趋势交易的策略交易 跑起来", nil)
|
||||
reply := a.Run("set up a BTC trend trading strategy and start it", nil)
|
||||
|
||||
if llm.calls != 5 {
|
||||
t.Fatalf("expected 5 LLM calls, got %d", llm.calls)
|
||||
@@ -370,15 +370,15 @@ func TestStartExistingTrader(t *testing.T) {
|
||||
llm := &mockLLM{responses: []*mcp.LLMResponse{
|
||||
toolCall("c1", "GET", "/api/my-traders", "{}"),
|
||||
toolCall("c2", "POST", "/api/traders/tr1/start", "{}"),
|
||||
textReply("交易员 BTC Trader 已启动。"),
|
||||
textReply("Trader BTC Trader has been started."),
|
||||
}}
|
||||
a := New(port, "tok", "test-user", mockGetLLM(llm), testPrompt)
|
||||
reply := a.Run("启动交易员", nil)
|
||||
reply := a.Run("start the trader", nil)
|
||||
|
||||
if calls["POST /api/traders/tr1/start"] != 1 {
|
||||
t.Errorf("expected trader to be started, got %d start calls", calls["POST /api/traders/tr1/start"])
|
||||
}
|
||||
if reply != "交易员 BTC Trader 已启动。" {
|
||||
if reply != "Trader BTC Trader has been started." {
|
||||
t.Fatalf("unexpected reply: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func (m *Manager) Run(chatID int64, userMessage string, onChunk func(string)) st
|
||||
case lane <- struct{}{}:
|
||||
case <-time.After(60 * time.Second):
|
||||
logger.Warnf("Agent: lane wait timeout for chat %d — previous message still processing", chatID)
|
||||
return "Previous message is still being processed. Please wait a moment and try again. / 上一条消息仍在处理中,请稍等片刻后再试。"
|
||||
return "Previous message is still being processed. Please wait a moment and try again."
|
||||
}
|
||||
defer func() { <-lane }()
|
||||
return a.Run(userMessage, onChunk)
|
||||
|
||||
@@ -36,7 +36,7 @@ The Account State block at the start of this conversation lists every resource w
|
||||
Read the id field from there and copy it verbatim — do not abbreviate, shorten, or guess.
|
||||
|
||||
## Behavior Rules
|
||||
1. Reply in the same language the user used (中文→中文, English→English)
|
||||
1. Reply in the same language the user used (match the user's language exactly)
|
||||
2. Keep final replies concise — show results, not process
|
||||
3. Ask for ALL missing required info in ONE message — never ask one field at a time
|
||||
4. When user provides enough info, act immediately — no confirmation needed
|
||||
@@ -77,7 +77,7 @@ Use this to:
|
||||
- Only include "config" when user explicitly requests custom settings (specific coins, custom leverage, different timeframes).
|
||||
- After POST: GET /api/strategies/:id to verify → show user: name, coin_source.source_type, key risk_control values
|
||||
|
||||
**"帮我配置策略并跑起来" / "create strategy and start" (full setup workflow)**:
|
||||
**"create strategy and start" (full setup workflow)**:
|
||||
Execute these steps IN ORDER with NO user confirmation between them:
|
||||
1. POST /api/strategies — body: {"name":"<descriptive name>"} — no config needed, defaults are complete
|
||||
2. GET /api/strategies/:id — verify strategy was saved
|
||||
|
||||
@@ -355,12 +355,12 @@ func statusMsg(st *store.Store, userID string, apiPort int, lang string) string
|
||||
missing := ""
|
||||
if lang == "zh" {
|
||||
if !hasModel {
|
||||
missing += "\n❌ AI 模型 → 设置 → AI 模型 → 添加"
|
||||
missing += "\n❌ AI Model → Settings → AI Models → Add"
|
||||
}
|
||||
if !hasExchange {
|
||||
missing += "\n❌ 交易所 → 设置 → 交易所 → 添加"
|
||||
missing += "\n❌ Exchange → Settings → Exchanges → Add"
|
||||
}
|
||||
return "⚙️ *需要完成初始配置*\n\n打开 Web 管理界面完成配置:\n→ " + webURL + "\n" + missing + "\n\n配置完成后发送 /start"
|
||||
return "⚙️ *Setup required*\n\nOpen the web dashboard to complete setup:\n→ " + webURL + "\n" + missing + "\n\nSend /start when done."
|
||||
}
|
||||
if !hasModel {
|
||||
missing += "\n❌ AI Model → Settings → AI Models → Add"
|
||||
@@ -373,16 +373,16 @@ func statusMsg(st *store.Store, userID string, apiPort int, lang string) string
|
||||
|
||||
// All configured — show ready state.
|
||||
if lang == "zh" {
|
||||
return `✅ *NOFX 就绪,开始交易吧!*
|
||||
return `✅ *NOFX is ready!*
|
||||
|
||||
直接告诉我你想做什么:
|
||||
Just tell me what you want:
|
||||
|
||||
📊 "查看我的持仓"
|
||||
💰 "账户余额多少"
|
||||
🤖 "帮我创建 BTC 趋势策略并启动"
|
||||
⏹ "停止所有交易员"
|
||||
📊 "Show my positions"
|
||||
💰 "What's my balance?"
|
||||
🤖 "Create a BTC trend strategy and start it"
|
||||
⏹ "Stop all traders"
|
||||
|
||||
/help 查看更多 · /lang 切换语言`
|
||||
/help for more · /lang to change language`
|
||||
}
|
||||
return `✅ *NOFX is ready!*
|
||||
|
||||
@@ -399,14 +399,14 @@ Just tell me what you want:
|
||||
// ── Language ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func langMenuMsg() string {
|
||||
return "🌐 *Choose your language*\n\n1 — English\n2 — 中文\n\nReply with 1 or 2"
|
||||
return "🌐 *Choose your language*\n\n1 — English\n2 — Chinese\n\nReply with 1 or 2"
|
||||
}
|
||||
|
||||
func parseLangChoice(text string) string {
|
||||
switch strings.TrimSpace(text) {
|
||||
case "1", "en", "EN", "English", "english":
|
||||
return "en"
|
||||
case "2", "zh", "ZH", "中文", "chinese", "Chinese":
|
||||
case "2", "zh", "ZH", "chinese", "Chinese":
|
||||
return "zh"
|
||||
}
|
||||
return ""
|
||||
@@ -416,26 +416,26 @@ func parseLangChoice(text string) string {
|
||||
|
||||
func helpMsg(lang string) string {
|
||||
if lang == "zh" {
|
||||
return `*NOFX 使用指南*
|
||||
return `*NOFX Help*
|
||||
|
||||
*查询*
|
||||
• "查看我的持仓"
|
||||
• "账户余额多少"
|
||||
• "列出我的交易员"
|
||||
*Query*
|
||||
• "Show my positions"
|
||||
• "What's my balance?"
|
||||
• "List my traders"
|
||||
|
||||
*创建 & 启动*
|
||||
• "帮我创建 BTC 趋势策略并跑起来"
|
||||
• "保守型策略,只交易 BTC 和 ETH"
|
||||
*Create & start*
|
||||
• "Create a BTC trend strategy and start it"
|
||||
• "Conservative strategy, BTC and ETH only"
|
||||
|
||||
*控制*
|
||||
• "启动交易员"
|
||||
• "暂停交易员"
|
||||
• "停止所有交易"
|
||||
*Control*
|
||||
• "Start trader"
|
||||
• "Pause trader"
|
||||
• "Stop all trading"
|
||||
|
||||
*命令*
|
||||
/start — 刷新状态
|
||||
/lang — 切换语言
|
||||
/help — 帮助`
|
||||
*Commands*
|
||||
/start — refresh status
|
||||
/lang — change language
|
||||
/help — show this`
|
||||
}
|
||||
return `*NOFX Help*
|
||||
|
||||
|
||||
@@ -177,8 +177,12 @@ type AutoTrader struct {
|
||||
gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading")
|
||||
claw402WalletAddr string // Claw402 wallet address (derived from private key at start)
|
||||
consecutiveAIFailures int // Consecutive AI call failures
|
||||
runtimeHealthMu sync.RWMutex // Guards safe mode + AI wallet health (loop writes, API reads)
|
||||
safeMode bool // Safe mode: no new positions, protect existing ones
|
||||
safeModeReason string // Why safe mode was activated
|
||||
aiWalletStatus string // "ok"|"low"|"empty"|"unknown" — see runtime_health.go
|
||||
aiWalletBalanceUSDC float64 // Last observed Base USDC balance of the claw402 wallet
|
||||
aiWalletCheckedAt time.Time // When the balance was last observed
|
||||
}
|
||||
|
||||
// NewAutoTrader creates an automatic trader
|
||||
@@ -418,6 +422,12 @@ func (at *AutoTrader) reloadStrategyConfigIfChanged() error {
|
||||
}
|
||||
strategyConfig.ClampLimits()
|
||||
|
||||
// NOTE: this used to hardcode the Autopilot book shape (6 positions ×
|
||||
// equity×1.2 notional), silently overriding whatever the user configured in
|
||||
// their strategy. Sizing now comes from the strategy's own RiskControl —
|
||||
// ClampLimits above bounds it (ratio 0.5–10, leverage caps), and the
|
||||
// margin auto-reduce at order time keeps the book solvent.
|
||||
|
||||
claw402Key := at.config.Claw402WalletKey
|
||||
if claw402Key == "" && at.config.AIModel == "claw402" && at.config.CustomAPIKey != "" {
|
||||
claw402Key = at.config.CustomAPIKey
|
||||
@@ -524,9 +534,6 @@ func (at *AutoTrader) Run() error {
|
||||
}
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(at.config.ScanInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Check if this is a grid trading strategy
|
||||
isGridStrategy := at.IsGridStrategy()
|
||||
if isGridStrategy {
|
||||
@@ -538,6 +545,7 @@ func (at *AutoTrader) Run() error {
|
||||
}
|
||||
|
||||
// Execute immediately on first run
|
||||
at.logInfof("▶️ Running first trading cycle immediately; next cycle starts after %v", at.config.ScanInterval)
|
||||
if isGridStrategy {
|
||||
if err := at.RunGridCycle(); err != nil {
|
||||
at.logErrorf("❌ Grid execution failed: %v", err)
|
||||
@@ -548,6 +556,9 @@ func (at *AutoTrader) Run() error {
|
||||
}
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(at.config.ScanInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
at.isRunningMutex.RLock()
|
||||
running := at.isRunning
|
||||
@@ -597,6 +608,12 @@ func (at *AutoTrader) GetID() string {
|
||||
return at.id
|
||||
}
|
||||
|
||||
// GetInitialBalance returns the account baseline used for performance metrics
|
||||
// (e.g. the drawdown equity curve).
|
||||
func (at *AutoTrader) GetInitialBalance() float64 {
|
||||
return at.initialBalance
|
||||
}
|
||||
|
||||
// GetUnderlyingTrader returns the underlying Trader interface implementation
|
||||
// This is used by grid trading and other components that need direct exchange access
|
||||
func (at *AutoTrader) GetUnderlyingTrader() Trader {
|
||||
@@ -699,7 +716,9 @@ func (at *AutoTrader) runPreLaunchChecks() {
|
||||
balance, err := wallet.QueryUSDCBalance(addr)
|
||||
if err != nil {
|
||||
logger.Warnf("⚠️ [%s] Could not query USDC balance: %v", at.name, err)
|
||||
at.markAIWalletHealthUnknown()
|
||||
} else {
|
||||
at.setAIWalletHealth(balance)
|
||||
// Estimate runway
|
||||
scanMinutes := int(at.config.ScanInterval.Minutes())
|
||||
modelName := at.config.CustomModelName
|
||||
|
||||
@@ -3,11 +3,11 @@ package trader
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"nofx/telemetry"
|
||||
"nofx/kernel"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"nofx/telemetry"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -18,13 +18,14 @@ func (at *AutoTrader) saveEquitySnapshot(ctx *kernel.Context) {
|
||||
}
|
||||
|
||||
snapshot := &store.EquitySnapshot{
|
||||
TraderID: at.id,
|
||||
Timestamp: time.Now().UTC(),
|
||||
TotalEquity: ctx.Account.TotalEquity,
|
||||
Balance: ctx.Account.TotalEquity - ctx.Account.UnrealizedPnL,
|
||||
UnrealizedPnL: ctx.Account.UnrealizedPnL,
|
||||
PositionCount: ctx.Account.PositionCount,
|
||||
MarginUsedPct: ctx.Account.MarginUsedPct,
|
||||
TraderID: at.id,
|
||||
Timestamp: time.Now().UTC(),
|
||||
TotalEquity: ctx.Account.TotalEquity,
|
||||
Balance: ctx.Account.TotalEquity - ctx.Account.UnrealizedPnL,
|
||||
AvailableBalance: ctx.Account.AvailableBalance,
|
||||
UnrealizedPnL: ctx.Account.UnrealizedPnL,
|
||||
PositionCount: ctx.Account.PositionCount,
|
||||
MarginUsedPct: ctx.Account.MarginUsedPct,
|
||||
}
|
||||
|
||||
if err := at.store.Equity().Save(snapshot); err != nil {
|
||||
@@ -90,6 +91,19 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime health: safe mode + AI fee wallet, so the dashboard can show a
|
||||
// persistent banner instead of the user digging through logs.
|
||||
safeMode, safeModeReason := at.safeModeState()
|
||||
result["safe_mode"] = safeMode
|
||||
if safeModeReason != "" {
|
||||
result["safe_mode_reason"] = safeModeReason
|
||||
}
|
||||
if status, balance, checkedAt := at.aiWalletHealth(); status != "" {
|
||||
result["ai_wallet_status"] = status
|
||||
result["ai_wallet_balance_usdc"] = balance
|
||||
result["ai_wallet_checked_at"] = checkedAt.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
116
trader/auto_trader_force.go
Normal file
116
trader/auto_trader_force.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"nofx/kernel"
|
||||
)
|
||||
|
||||
// forcedCoverageMinScore is the minimum absolute board z-score a candidate
|
||||
// needs before the engine will force-open it for book balance. Near-neutral
|
||||
// signals (|z| < ~0.3) proved a systematic loser, but a 0.75 floor was too
|
||||
// strict: in a long-leaning tape every bearish candidate scored below it, so
|
||||
// no short ever opened and the book became a one-directional long bet that
|
||||
// drew down hard. 0.4 keeps genuine directional signals while still filtering
|
||||
// pure noise, so the book can actually hedge.
|
||||
const forcedCoverageMinScore = 0.4
|
||||
|
||||
// ensureLongShortCoverage tops the book up toward roughly half the
|
||||
// MaxPositions slots long and half short — but only with candidates whose
|
||||
// directional signal is actually strong (see forcedCoverageMinScore). The AI
|
||||
// still drives selection/sizing whenever it acts; this is a deterministic
|
||||
// top-up, and an unbalanced book is preferred over a forced weak trade.
|
||||
//
|
||||
// Forced opens are sized from account equity via applyAutopilotFullSizeOpen and
|
||||
// run through the same code-enforced risk checks (position-value ratio, minimum
|
||||
// size, margin) as any other open. Guards:
|
||||
// - skipped entirely in safe mode (AI unhealthy),
|
||||
// - scoped to the vergex_signal source (the only one with directional bias),
|
||||
// - requires |signal score| >= forcedCoverageMinScore,
|
||||
// - never exceeds MaxPositions,
|
||||
// - never doubles a base symbol already held or already in the decision set.
|
||||
func (at *AutoTrader) ensureLongShortCoverage(decisions []kernel.Decision, ctx *kernel.Context, equity float64) []kernel.Decision {
|
||||
if at == nil || ctx == nil || at.isSafeMode() {
|
||||
return decisions
|
||||
}
|
||||
if at.config.StrategyConfig == nil || at.config.StrategyConfig.CoinSource.SourceType != "vergex_signal" {
|
||||
return decisions
|
||||
}
|
||||
if at.strategyEngine == nil || equity <= 0 {
|
||||
return decisions
|
||||
}
|
||||
|
||||
maxPos := at.config.StrategyConfig.RiskControl.MaxPositions
|
||||
if maxPos < 2 {
|
||||
return decisions
|
||||
}
|
||||
// Aim to hold a balanced book: roughly half the slots long, half short.
|
||||
targetLong := (maxPos + 1) / 2
|
||||
targetShort := maxPos / 2
|
||||
|
||||
held := make(map[string]bool)
|
||||
longCount, shortCount, posCount := 0, 0, 0
|
||||
for _, p := range ctx.Positions {
|
||||
held[universeBaseKey(p.Symbol)] = true
|
||||
posCount++
|
||||
if strings.EqualFold(p.Side, "long") {
|
||||
longCount++
|
||||
} else if strings.EqualFold(p.Side, "short") {
|
||||
shortCount++
|
||||
}
|
||||
}
|
||||
for _, d := range decisions {
|
||||
held[universeBaseKey(d.Symbol)] = true
|
||||
switch d.Action {
|
||||
case "open_long":
|
||||
longCount++
|
||||
posCount++
|
||||
case "open_short":
|
||||
shortCount++
|
||||
posCount++
|
||||
}
|
||||
}
|
||||
|
||||
bullish, bearish := at.strategyEngine.DirectionalCandidates()
|
||||
|
||||
// fill a direction up to its target, drawing from the strongest unused
|
||||
// candidates that clear the signal-strength floor, never exceeding
|
||||
// MaxPositions.
|
||||
fill := func(action string, cands []kernel.DirectionalCandidate, have, target int) {
|
||||
for _, c := range cands {
|
||||
if have >= target {
|
||||
return
|
||||
}
|
||||
if maxPos > 0 && posCount >= maxPos {
|
||||
return
|
||||
}
|
||||
if math.Abs(c.Score) < forcedCoverageMinScore {
|
||||
// candidates are rank-ordered; weaker ones may still follow,
|
||||
// so keep scanning instead of breaking
|
||||
at.logInfof("⚖️ Skipped forced %s %s: signal score %.2f below %.2f floor", action, c.Symbol, c.Score, forcedCoverageMinScore)
|
||||
continue
|
||||
}
|
||||
b := universeBaseKey(c.Symbol)
|
||||
if b == "" || held[b] {
|
||||
continue
|
||||
}
|
||||
d := kernel.Decision{
|
||||
Action: action,
|
||||
Symbol: c.Symbol,
|
||||
Confidence: 70,
|
||||
Reasoning: "Forced " + action + " to fill the balanced long/short book (autopilot)",
|
||||
}
|
||||
at.applyAutopilotFullSizeOpen(&d, equity)
|
||||
decisions = append(decisions, d)
|
||||
held[b] = true
|
||||
have++
|
||||
posCount++
|
||||
at.logInfof("⚖️ Forced %s %s (score %.2f, account-sized %.2f USDT, %dx)", action, c.Symbol, c.Score, d.PositionSizeUSD, d.Leverage)
|
||||
}
|
||||
}
|
||||
|
||||
fill("open_long", bullish, longCount, targetLong)
|
||||
fill("open_short", bearish, shortCount, targetShort)
|
||||
return decisions
|
||||
}
|
||||
57
trader/auto_trader_force_test.go
Normal file
57
trader/auto_trader_force_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"nofx/kernel"
|
||||
"nofx/store"
|
||||
)
|
||||
|
||||
func baseForceTrader() *AutoTrader {
|
||||
cfg := store.GetDefaultStrategyConfig("en")
|
||||
cfg.CoinSource.SourceType = "vergex_signal"
|
||||
cfg.RiskControl.MaxPositions = 5
|
||||
cfg.RiskControl.AltcoinMaxLeverage = 10
|
||||
cfg.RiskControl.AltcoinMaxPositionValueRatio = 10
|
||||
at := &AutoTrader{config: AutoTraderConfig{StrategyConfig: &cfg}}
|
||||
at.strategyEngine = kernel.NewStrategyEngine(&cfg) // empty ranking cache
|
||||
return at
|
||||
}
|
||||
|
||||
func TestEnsureLongShortCoverageSafeModeSkips(t *testing.T) {
|
||||
at := baseForceTrader()
|
||||
at.safeMode = true
|
||||
out := at.ensureLongShortCoverage(nil, &kernel.Context{}, 100)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("safe mode must not force opens, got %d", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLongShortCoverageNonVergexSkips(t *testing.T) {
|
||||
at := baseForceTrader()
|
||||
at.config.StrategyConfig.CoinSource.SourceType = "static"
|
||||
out := at.ensureLongShortCoverage(nil, &kernel.Context{}, 100)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("non-vergex source must not force opens, got %d", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLongShortCoverageBothPresentNoop(t *testing.T) {
|
||||
at := baseForceTrader()
|
||||
in := []kernel.Decision{
|
||||
{Action: "open_long", Symbol: "xyz:AAPL"},
|
||||
{Action: "open_short", Symbol: "BTC"},
|
||||
}
|
||||
out := at.ensureLongShortCoverage(in, &kernel.Context{}, 100)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("both directions already present -> no force, got %d", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureLongShortCoverageNoCandidatesNoForce(t *testing.T) {
|
||||
at := baseForceTrader() // empty ranking cache -> no directional candidates
|
||||
out := at.ensureLongShortCoverage(nil, &kernel.Context{}, 100)
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("no candidates available -> nothing to force, got %d", len(out))
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,12 @@ package trader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"nofx/kernel"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/mcp/payment"
|
||||
"nofx/provider/hyperliquid"
|
||||
"nofx/store"
|
||||
"nofx/wallet"
|
||||
@@ -141,10 +143,21 @@ func (at *AutoTrader) runCycle() error {
|
||||
record.Success = false
|
||||
record.ErrorMessage = fmt.Sprintf("Failed to get AI decision: %v", err)
|
||||
|
||||
// Payment-layer rejection means the AI fee wallet is definitively out
|
||||
// of funds — surface it as structured health state (GetStatus), not
|
||||
// just a log line.
|
||||
var insufficientFunds *payment.ErrInsufficientFunds
|
||||
if errors.As(err, &insufficientFunds) {
|
||||
at.markAIWalletEmptyFromPayment(insufficientFunds.Balance)
|
||||
record.ErrorMessage = fmt.Sprintf(
|
||||
"AI fee wallet out of funds: balance $%.2f USDC, next call needs ~$%.2f. Top up the Base USDC wallet.",
|
||||
insufficientFunds.Balance, insufficientFunds.Needed,
|
||||
)
|
||||
}
|
||||
|
||||
// Activate safe mode after 3 consecutive failures
|
||||
if at.consecutiveAIFailures >= 3 && !at.safeMode {
|
||||
at.safeMode = true
|
||||
at.safeModeReason = fmt.Sprintf("AI failed %d consecutive times: %v", at.consecutiveAIFailures, err)
|
||||
if at.consecutiveAIFailures >= 3 && !at.isSafeMode() {
|
||||
at.setSafeMode(true, fmt.Sprintf("AI failed %d consecutive times: %v", at.consecutiveAIFailures, err))
|
||||
at.logErrorf("🛡️ SAFE MODE ACTIVATED — AI failed %d times in a row. No new positions will be opened. Existing positions are protected with current stop-loss settings.", at.consecutiveAIFailures)
|
||||
at.logErrorf("🛡️ Reason: %v", err)
|
||||
at.logErrorf("🛡️ Action: Will keep trying AI each cycle. Safe mode auto-deactivates when AI recovers.")
|
||||
@@ -172,7 +185,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
}
|
||||
|
||||
// In safe mode, don't return error — keep the loop running to retry next cycle
|
||||
if at.safeMode {
|
||||
if at.isSafeMode() {
|
||||
at.logWarnf("🛡️ Safe mode: skipping this cycle, will retry in %v", at.config.ScanInterval)
|
||||
return nil
|
||||
}
|
||||
@@ -185,10 +198,9 @@ func (at *AutoTrader) runCycle() error {
|
||||
at.logInfof("✅ AI recovered after %d consecutive failures", at.consecutiveAIFailures)
|
||||
}
|
||||
at.consecutiveAIFailures = 0
|
||||
if at.safeMode {
|
||||
if at.isSafeMode() {
|
||||
at.logInfof("🛡️ SAFE MODE DEACTIVATED — AI is working again. Resuming normal trading.")
|
||||
at.safeMode = false
|
||||
at.safeModeReason = ""
|
||||
at.setSafeMode(false, "")
|
||||
}
|
||||
|
||||
// // 5. Print system prompt
|
||||
@@ -222,6 +234,9 @@ func (at *AutoTrader) runCycle() error {
|
||||
// 8. Sort decisions: ensure close positions first, then open positions (prevent position stacking overflow)
|
||||
sortedDecisions := sortDecisionsByPriority(aiDecision.Decisions)
|
||||
sortedDecisions = at.filterDecisionsToStrategyUniverse(sortedDecisions, ctx)
|
||||
// Per-cycle long/short coverage: if the AI left a direction uncovered, force
|
||||
// the strongest bullish/bearish candidate (account-sized, risk-enforced).
|
||||
sortedDecisions = at.ensureLongShortCoverage(sortedDecisions, ctx, ctx.Account.TotalEquity)
|
||||
|
||||
logger.Info("🔄 Execution order (optimized): Close positions first → Open positions later")
|
||||
for i, d := range sortedDecisions {
|
||||
@@ -239,7 +254,7 @@ func (at *AutoTrader) runCycle() error {
|
||||
}
|
||||
|
||||
// Safe mode: filter out open positions, only allow close/hold
|
||||
if at.safeMode {
|
||||
if at.isSafeMode() {
|
||||
filtered := make([]kernel.Decision, 0)
|
||||
for _, d := range sortedDecisions {
|
||||
if d.Action == "open_long" || d.Action == "open_short" {
|
||||
@@ -637,7 +652,7 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
|
||||
}
|
||||
}
|
||||
// Get trading statistics for AI context
|
||||
stats, err := at.store.Position().GetFullStats(at.id)
|
||||
stats, err := at.store.Position().GetFullStats(at.id, at.initialBalance)
|
||||
if err != nil {
|
||||
at.logWarnf("⚠️ Failed to get trading stats: %v", err)
|
||||
} else if stats == nil {
|
||||
@@ -767,10 +782,12 @@ func (at *AutoTrader) checkClaw402Balance() {
|
||||
balance, err := wallet.QueryUSDCBalance(at.claw402WalletAddr)
|
||||
if err != nil {
|
||||
at.logWarnf("⚠️ Failed to query USDC balance: %v", err)
|
||||
at.markAIWalletHealthUnknown()
|
||||
return
|
||||
}
|
||||
|
||||
if balance < 1.0 {
|
||||
at.setAIWalletHealth(balance)
|
||||
if balance < aiWalletLowThresholdUSDC {
|
||||
at.logWarnf("⚠️ Low USDC balance: $%.2f — AI may stop soon!", balance)
|
||||
}
|
||||
if balance <= 0 {
|
||||
|
||||
@@ -10,15 +10,25 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
autopilotMinHoldDuration = 45 * time.Minute
|
||||
autopilotNoiseCloseHoldDuration = 90 * time.Minute
|
||||
autopilotReentryCooldown = 90 * time.Minute
|
||||
autopilotMaxOpensPerHour = 1
|
||||
autopilotMaxOpensPerCycle = 1
|
||||
earlyCloseStopLossBypassPct = -2.5
|
||||
earlyCloseTakeProfitBypassPct = 5.0
|
||||
noiseCloseLossFloorPct = -1.0
|
||||
noiseCloseProfitCeilingPct = 2.0
|
||||
// "Hold for big moves, don't churn" regime. Live history showed the
|
||||
// account bleeding to death by fees: 0.3-0.5% in/out moves where a ~0.14%
|
||||
// round-trip fee ate 30-50% of every small winner. These values force
|
||||
// positions to be held for hours and to develop meaningful moves before
|
||||
// closing, and cut the trade frequency hard.
|
||||
autopilotMinHoldDuration = 4 * time.Hour
|
||||
autopilotNoiseCloseHoldDuration = 8 * time.Hour
|
||||
autopilotReentryCooldown = 3 * time.Hour
|
||||
// Drastically cut churn: at most a couple of new positions per hour/cycle.
|
||||
autopilotMaxOpensPerHour = 3
|
||||
autopilotMaxOpensPerCycle = 2
|
||||
// Wide, asymmetric exits. Cut a loser only at a real -5% (at 5x leverage
|
||||
// that is -25% of margin — survivable), let a winner run to +12% before
|
||||
// any early take-profit. The noise band (-4%..+6%) blocks closing on the
|
||||
// small moves that were grinding the account to nothing.
|
||||
earlyCloseStopLossBypassPct = -5.0
|
||||
earlyCloseTakeProfitBypassPct = 12.0
|
||||
noiseCloseLossFloorPct = -4.0
|
||||
noiseCloseProfitCeilingPct = 6.0
|
||||
)
|
||||
|
||||
func isOpenAction(action string) bool {
|
||||
|
||||
@@ -32,7 +32,8 @@ func TestTradeThrottleBlocksEarlyNoiseClose(t *testing.T) {
|
||||
|
||||
func TestTradeThrottleAllowsEarlyHardStop(t *testing.T) {
|
||||
at := &AutoTrader{}
|
||||
ctx := throttleContext("xyz:INTC", "long", 20*time.Minute, -3.0)
|
||||
// Only a real -5% stop bypasses the min hold now.
|
||||
ctx := throttleContext("xyz:INTC", "long", 20*time.Minute, -6.0)
|
||||
|
||||
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
||||
if reason != "" {
|
||||
@@ -42,7 +43,8 @@ func TestTradeThrottleAllowsEarlyHardStop(t *testing.T) {
|
||||
|
||||
func TestTradeThrottleBlocksFlatCloseInsideNoiseWindow(t *testing.T) {
|
||||
at := &AutoTrader{}
|
||||
ctx := throttleContext("xyz:INTC", "long", 60*time.Minute, 0.4)
|
||||
// Held past the 4h min hold but still inside the wide -4%..+6% noise band.
|
||||
ctx := throttleContext("xyz:INTC", "long", 5*time.Hour, 0.4)
|
||||
|
||||
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
||||
if !strings.Contains(reason, "noise band") {
|
||||
@@ -52,7 +54,8 @@ func TestTradeThrottleBlocksFlatCloseInsideNoiseWindow(t *testing.T) {
|
||||
|
||||
func TestTradeThrottleAllowsConfirmedLossAfterMinimumHold(t *testing.T) {
|
||||
at := &AutoTrader{}
|
||||
ctx := throttleContext("xyz:INTC", "long", 60*time.Minute, -1.2)
|
||||
// Past the 4h min hold, loss beyond the -4% noise floor → close allowed.
|
||||
ctx := throttleContext("xyz:INTC", "long", 5*time.Hour, -4.5)
|
||||
|
||||
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "close_long"}, ctx, 0)
|
||||
if reason != "" {
|
||||
@@ -60,13 +63,29 @@ func TestTradeThrottleAllowsConfirmedLossAfterMinimumHold(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTradeThrottleBlocksSecondOpenInCycle(t *testing.T) {
|
||||
func TestTradeThrottleAllowsLongShortPairInCycle(t *testing.T) {
|
||||
at := &AutoTrader{}
|
||||
ctx := &kernel.Context{}
|
||||
|
||||
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_long"}, ctx, 1)
|
||||
if !strings.Contains(reason, "only 1 new position") {
|
||||
t.Fatalf("expected second open in cycle to be blocked, got %q", reason)
|
||||
// One open already queued this cycle (e.g. the long) — the second open
|
||||
// (the short) must still be allowed so a directional pair can open.
|
||||
reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_short"}, ctx, 1)
|
||||
if reason != "" {
|
||||
t.Fatalf("expected the second (short) open in cycle to be allowed, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTradeThrottleBlocksOpensOverCycleCap(t *testing.T) {
|
||||
at := &AutoTrader{}
|
||||
ctx := &kernel.Context{}
|
||||
|
||||
// under the 2-per-cycle cap, a further open is allowed
|
||||
if reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_long"}, ctx, 1); reason != "" {
|
||||
t.Fatalf("expected open within the 2-per-cycle cap to be allowed, got %q", reason)
|
||||
}
|
||||
// at the cap, the next open is blocked
|
||||
if reason := at.tradeThrottleReason(kernel.Decision{Symbol: "xyz:INTC", Action: "open_long"}, ctx, 2); !strings.Contains(reason, "2 new position") {
|
||||
t.Fatalf("expected open beyond the 2-per-cycle cap to be blocked, got %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ func TestDefaultBuilderIsHardcodedToApprovedFeeTier(t *testing.T) {
|
||||
if got := defaultBuilder.Builder; got != "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d" {
|
||||
t.Fatalf("defaultBuilder.Builder = %s, want hardcoded NOFX builder", got)
|
||||
}
|
||||
// Fee is in tenths of a basis point: 50 = 5 bps = 0.05% (万5).
|
||||
// Fee is in tenths of a basis point: 50 = 5 bps = 0.05% (5 per 10,000).
|
||||
// Must match defaultHyperliquidBuilderMaxFee on the API side and the
|
||||
// frontend HYPERLIQUID_BUILDER_MAX_FEE constant the user signs against.
|
||||
if got := defaultBuilder.Fee; got != 50 {
|
||||
|
||||
@@ -20,13 +20,16 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
||||
return fmt.Errorf("store is nil")
|
||||
}
|
||||
|
||||
// Get recent trades (last 24 hours)
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
// Look back 7 days. GetTrades now pulls up to 2000 recent fills (UserFills)
|
||||
// and filters to this window, so a wide lookback backfills any fills missed
|
||||
// during past outages/gaps without dropping recent ones. Dedup by trade ID
|
||||
// keeps re-processing idempotent.
|
||||
startTime := time.Now().Add(-7 * 24 * time.Hour)
|
||||
|
||||
logger.Infof("🔄 Syncing Hyperliquid trades from: %s", startTime.Format(time.RFC3339))
|
||||
|
||||
// Use GetTrades method to fetch trade records
|
||||
trades, err := t.GetTrades(startTime, 1000)
|
||||
trades, err := t.GetTrades(startTime, 2000)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get trades: %w", err)
|
||||
}
|
||||
@@ -133,9 +136,43 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
||||
}
|
||||
|
||||
logger.Infof("✅ Order sync completed: %d new trades synced", syncedCount)
|
||||
|
||||
// Reconcile local OPEN rows against the exchange's live book. Without
|
||||
// this, any missed/unmatched fill leaves a zombie OPEN row that swallows
|
||||
// every later close as a "partial close" — its realized PnL then never
|
||||
// reaches the closed-trade statistics. Scoped by exchange account so rows
|
||||
// left by prior autopilot incarnations are healed too.
|
||||
if err := t.reconcilePositions(exchangeID, positionStore); err != nil {
|
||||
logger.Infof("⚠️ Position reconcile skipped: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcilePositions builds the live (symbol, side) → quantity map from the
|
||||
// exchange (core perps + xyz dex) and lets the store close/trim any local
|
||||
// OPEN rows on this exchange account the exchange no longer backs.
|
||||
func (t *HyperliquidTrader) reconcilePositions(exchangeID string, positionStore *store.PositionStore) error {
|
||||
livePositions, err := t.GetPositions()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get live positions: %w", err)
|
||||
}
|
||||
|
||||
liveQty := make(map[string]float64, len(livePositions))
|
||||
for _, pos := range livePositions {
|
||||
symbol, _ := pos["symbol"].(string)
|
||||
side, _ := pos["side"].(string)
|
||||
qty, _ := pos["positionAmt"].(float64)
|
||||
if symbol == "" || qty <= 0 {
|
||||
continue
|
||||
}
|
||||
liveQty[store.LivePositionKey(market.Normalize(symbol), side)] += qty
|
||||
}
|
||||
|
||||
_, err = positionStore.ReconcileOpenPositionsWithLive(exchangeID, liveQty)
|
||||
return err
|
||||
}
|
||||
|
||||
// StartOrderSync starts background order sync task
|
||||
func (t *HyperliquidTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration, stop <-chan struct{}) {
|
||||
syncloop.Run(stop, interval, "Hyperliquid", func() error {
|
||||
|
||||
@@ -63,7 +63,7 @@ var xyzDexAssets = map[string]bool{
|
||||
// Users approve this builder during the top-right Hyperliquid connect flow before
|
||||
// their generated agent wallet is saved for live trading.
|
||||
//
|
||||
// Fee is in tenths of a basis point: 50 = 5 bps = 0.05% (万5). Existing
|
||||
// Fee is in tenths of a basis point: 50 = 5 bps = 0.05% (5 per 10,000). Existing
|
||||
// approvals at the prior 0.1% cap remain valid on-chain because 0.05% is
|
||||
// still within their approved max.
|
||||
var defaultBuilder = &hyperliquid.BuilderInfo{
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
hl "github.com/sonirico/go-hyperliquid"
|
||||
)
|
||||
|
||||
// GetBalance gets account balance
|
||||
@@ -129,23 +131,29 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
|
||||
logger.Infof(" └─ %s: size=%s, entryPx=%s, posValue=%s, pnl=%s",
|
||||
pos.Position.Coin, pos.Position.Szi, entryPx, pos.Position.PositionValue, pos.Position.UnrealizedPnl)
|
||||
}
|
||||
xyzWalletBalance := xyzAccountValue - xyzUnrealizedPnl
|
||||
|
||||
// Step 6: Correctly handle Spot + Perpetuals + xyz dex balance
|
||||
// Important: Each account is independent, manual transfers required
|
||||
totalWalletBalance := walletBalanceWithoutUnrealized + spotUSDCBalance + xyzWalletBalance
|
||||
totalUnrealizedPnlAll := totalUnrealizedPnl + xyzUnrealizedPnl
|
||||
|
||||
// Calculate total equity properly: perpAccountValue + spotUSDCBalance + xyzAccountValue
|
||||
// Note: totalWalletBalance + totalUnrealizedPnlAll should equal this
|
||||
totalEquityCalculated := accountValue + spotUSDCBalance + xyzAccountValue
|
||||
xyzMarginUsed := calculateXYZMarginUsed(xyzPositions)
|
||||
balanceBreakdown := calculateHyperliquidBalanceBreakdown(
|
||||
t.isUnifiedAccount,
|
||||
spotUSDCBalance,
|
||||
accountValue,
|
||||
totalUnrealizedPnl,
|
||||
totalMarginUsed,
|
||||
availableBalance,
|
||||
xyzAccountValue,
|
||||
xyzUnrealizedPnl,
|
||||
xyzMarginUsed,
|
||||
)
|
||||
totalWalletBalance := balanceBreakdown.TotalWalletBalance
|
||||
totalUnrealizedPnlAll := balanceBreakdown.TotalUnrealizedProfit
|
||||
totalEquityCalculated := balanceBreakdown.TotalEquity
|
||||
availableBalance = balanceBreakdown.AvailableBalance
|
||||
|
||||
// Step 7: Unified Account mode - Spot USDC is used as collateral for Perps
|
||||
// In this mode, available balance includes Spot USDC since it can be used for Perp margin
|
||||
// In this mode, xyz/core account values are collateral views backed by the
|
||||
// same Spot USDC. They must not be added on top of Spot or the dashboard
|
||||
// will double count equity after a position opens.
|
||||
if t.isUnifiedAccount && spotUSDCBalance > 0 {
|
||||
// Add Spot balance to available balance for trading
|
||||
availableBalance = availableBalance + spotUSDCBalance
|
||||
logger.Infof("✓ Unified Account: Spot %.2f USDC added to available balance (total: %.2f)",
|
||||
logger.Infof("✓ Unified Account: Spot %.2f USDC used as shared collateral (available: %.2f)",
|
||||
spotUSDCBalance, availableBalance)
|
||||
}
|
||||
|
||||
@@ -160,6 +168,7 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
|
||||
result["xyzDexBalance"] = xyzAccountValue // xyz dex equity (stock perps, forex, commodities)
|
||||
result["xyzDexUnrealizedPnl"] = xyzUnrealizedPnl // xyz dex unrealized PnL
|
||||
result["perpAccountValue"] = accountValue // Perp account value for debugging
|
||||
result["totalMarginUsed"] = balanceBreakdown.TotalMarginUsed
|
||||
|
||||
logger.Infof("✓ Hyperliquid complete account:")
|
||||
logger.Infof(" • Spot balance: %.2f USDC", spotUSDCBalance)
|
||||
@@ -171,15 +180,93 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
|
||||
logger.Infof(" • Margin used: %.2f USDC", totalMarginUsed)
|
||||
logger.Infof(" • xyz dex equity: %.2f USDC (wallet %.2f + unrealized %.2f)",
|
||||
xyzAccountValue,
|
||||
xyzWalletBalance,
|
||||
balanceBreakdown.XYZWalletBalance,
|
||||
xyzUnrealizedPnl)
|
||||
logger.Infof(" • Total assets (Perp+Spot+xyz): %.2f USDC", totalWalletBalance)
|
||||
logger.Infof(" ⭐ Total: %.2f USDC | Perp: %.2f | Spot: %.2f | xyz: %.2f",
|
||||
totalWalletBalance, availableBalance, spotUSDCBalance, xyzAccountValue)
|
||||
logger.Infof(" • Total wallet balance: %.2f USDC", totalWalletBalance)
|
||||
logger.Infof(" ⭐ Total equity: %.2f USDC | Available: %.2f | Spot: %.2f | xyz view: %.2f",
|
||||
totalEquityCalculated, availableBalance, spotUSDCBalance, xyzAccountValue)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type hyperliquidBalanceBreakdown struct {
|
||||
TotalWalletBalance float64
|
||||
TotalEquity float64
|
||||
AvailableBalance float64
|
||||
TotalUnrealizedProfit float64
|
||||
TotalMarginUsed float64
|
||||
PerpWalletBalance float64
|
||||
XYZWalletBalance float64
|
||||
}
|
||||
|
||||
func calculateHyperliquidBalanceBreakdown(
|
||||
isUnifiedAccount bool,
|
||||
spotUSDCBalance float64,
|
||||
perpAccountValue float64,
|
||||
perpUnrealizedPnl float64,
|
||||
perpMarginUsed float64,
|
||||
perpWithdrawable float64,
|
||||
xyzAccountValue float64,
|
||||
xyzUnrealizedPnl float64,
|
||||
xyzMarginUsed float64,
|
||||
) hyperliquidBalanceBreakdown {
|
||||
perpWalletBalance := perpAccountValue - perpUnrealizedPnl
|
||||
xyzWalletBalance := xyzAccountValue - xyzUnrealizedPnl
|
||||
totalUnrealizedPnl := perpUnrealizedPnl + xyzUnrealizedPnl
|
||||
totalMarginUsed := perpMarginUsed + xyzMarginUsed
|
||||
|
||||
if isUnifiedAccount && spotUSDCBalance > 0 {
|
||||
totalEquity := spotUSDCBalance + totalUnrealizedPnl
|
||||
availableBalance := totalEquity - totalMarginUsed
|
||||
if availableBalance < 0 {
|
||||
availableBalance = 0
|
||||
}
|
||||
return hyperliquidBalanceBreakdown{
|
||||
TotalWalletBalance: spotUSDCBalance,
|
||||
TotalEquity: totalEquity,
|
||||
AvailableBalance: availableBalance,
|
||||
TotalUnrealizedProfit: totalUnrealizedPnl,
|
||||
TotalMarginUsed: totalMarginUsed,
|
||||
PerpWalletBalance: perpWalletBalance,
|
||||
XYZWalletBalance: xyzWalletBalance,
|
||||
}
|
||||
}
|
||||
|
||||
availableBalance := perpWithdrawable
|
||||
if availableBalance == 0 {
|
||||
availableBalance = perpAccountValue - perpMarginUsed
|
||||
}
|
||||
if availableBalance < 0 {
|
||||
availableBalance = 0
|
||||
}
|
||||
|
||||
return hyperliquidBalanceBreakdown{
|
||||
TotalWalletBalance: perpWalletBalance + spotUSDCBalance + xyzWalletBalance,
|
||||
TotalEquity: perpAccountValue + spotUSDCBalance + xyzAccountValue,
|
||||
AvailableBalance: availableBalance,
|
||||
TotalUnrealizedProfit: totalUnrealizedPnl,
|
||||
TotalMarginUsed: totalMarginUsed,
|
||||
PerpWalletBalance: perpWalletBalance,
|
||||
XYZWalletBalance: xyzWalletBalance,
|
||||
}
|
||||
}
|
||||
|
||||
func calculateXYZMarginUsed(positions []xyzAssetPosition) float64 {
|
||||
total := 0.0
|
||||
for _, pos := range positions {
|
||||
positionValue, _ := strconv.ParseFloat(pos.Position.PositionValue, 64)
|
||||
if positionValue < 0 {
|
||||
positionValue = -positionValue
|
||||
}
|
||||
leverage := float64(pos.Position.Leverage.Value)
|
||||
if leverage <= 0 {
|
||||
leverage = 1
|
||||
}
|
||||
total += positionValue / leverage
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// xyzDexState represents the clearinghouse state for xyz dex
|
||||
type xyzDexState struct {
|
||||
MarginSummary *xyzMarginSummary `json:"marginSummary,omitempty"`
|
||||
@@ -463,15 +550,23 @@ func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]type
|
||||
|
||||
// GetTrades retrieves trade history from Hyperliquid
|
||||
func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]types.TradeRecord, error) {
|
||||
// Use UserFillsByTime API
|
||||
// Use UserFills (returns up to 2000 recent fills) rather than
|
||||
// UserFillsByTime, which is hard-capped at 100 fills per response. At
|
||||
// this trading frequency the account exceeds 100 fills/24h, so
|
||||
// UserFillsByTime silently dropped ~20% of fills — skewing recorded PnL
|
||||
// and fees away from the exchange truth. 2000 recent fills covers many
|
||||
// days of history; we filter to startTime client-side.
|
||||
startTimeMs := startTime.UnixMilli()
|
||||
fills, err := t.exchange.Info().UserFillsByTime(t.ctx, t.walletAddr, startTimeMs, nil, nil)
|
||||
fills, err := t.exchange.Info().UserFills(t.ctx, hl.UserFillsParams{Address: t.walletAddr})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user fills: %w", err)
|
||||
}
|
||||
|
||||
var trades []types.TradeRecord
|
||||
for _, fill := range fills {
|
||||
if fill.Time < startTimeMs {
|
||||
continue
|
||||
}
|
||||
price, _ := strconv.ParseFloat(fill.Price, 64)
|
||||
qty, _ := strconv.ParseFloat(fill.Size, 64)
|
||||
fee, _ := strconv.ParseFloat(fill.Fee, 64)
|
||||
|
||||
48
trader/hyperliquid/trader_account_test.go
Normal file
48
trader/hyperliquid/trader_account_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package hyperliquid
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUnifiedAccountDoesNotDoubleCountXYZAccountValue(t *testing.T) {
|
||||
breakdown := calculateHyperliquidBalanceBreakdown(
|
||||
true,
|
||||
26.33, // Spot USDC collateral
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
25.96, // xyz account value is a view of the same shared collateral
|
||||
-0.32,
|
||||
25.96,
|
||||
)
|
||||
|
||||
if breakdown.TotalEquity < 25.99 || breakdown.TotalEquity > 26.02 {
|
||||
t.Fatalf("expected total equity to be spot + unrealized pnl, got %.4f", breakdown.TotalEquity)
|
||||
}
|
||||
if breakdown.TotalEquity > 40 {
|
||||
t.Fatalf("unified collateral was double-counted: %.4f", breakdown.TotalEquity)
|
||||
}
|
||||
if breakdown.AvailableBalance > 0.1 {
|
||||
t.Fatalf("expected almost no free collateral with full-size margin, got %.4f", breakdown.AvailableBalance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeparateAccountsStillAddIndependentBalances(t *testing.T) {
|
||||
breakdown := calculateHyperliquidBalanceBreakdown(
|
||||
false,
|
||||
30,
|
||||
10,
|
||||
1,
|
||||
2,
|
||||
8,
|
||||
5,
|
||||
-0.5,
|
||||
1,
|
||||
)
|
||||
|
||||
if breakdown.TotalEquity != 45 {
|
||||
t.Fatalf("expected independent accounts to add to 45, got %.4f", breakdown.TotalEquity)
|
||||
}
|
||||
if breakdown.TotalWalletBalance != 44.5 {
|
||||
t.Fatalf("expected wallet balance 44.5, got %.4f", breakdown.TotalWalletBalance)
|
||||
}
|
||||
}
|
||||
82
trader/runtime_health.go
Normal file
82
trader/runtime_health.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Runtime health state written by the run-loop goroutine and read by the API
|
||||
// layer (GetStatus). Everything here goes through runtimeHealthMu so the
|
||||
// dashboard can poll without racing the trading loop.
|
||||
|
||||
// AI fee wallet health statuses exposed via GetStatus as "ai_wallet_status".
|
||||
const (
|
||||
AIWalletStatusOK = "ok"
|
||||
AIWalletStatusLow = "low"
|
||||
AIWalletStatusEmpty = "empty"
|
||||
AIWalletStatusUnknown = "unknown"
|
||||
)
|
||||
|
||||
// aiWalletLowThresholdUSDC mirrors api.MinAIFeeUSDC: below this the wallet
|
||||
// cannot reliably pay for the next AI/data calls.
|
||||
const aiWalletLowThresholdUSDC = 1.0
|
||||
|
||||
func (at *AutoTrader) setSafeMode(active bool, reason string) {
|
||||
at.runtimeHealthMu.Lock()
|
||||
at.safeMode = active
|
||||
at.safeModeReason = reason
|
||||
at.runtimeHealthMu.Unlock()
|
||||
}
|
||||
|
||||
func (at *AutoTrader) isSafeMode() bool {
|
||||
at.runtimeHealthMu.RLock()
|
||||
defer at.runtimeHealthMu.RUnlock()
|
||||
return at.safeMode
|
||||
}
|
||||
|
||||
func (at *AutoTrader) safeModeState() (bool, string) {
|
||||
at.runtimeHealthMu.RLock()
|
||||
defer at.runtimeHealthMu.RUnlock()
|
||||
return at.safeMode, at.safeModeReason
|
||||
}
|
||||
|
||||
// setAIWalletHealth records a fresh balance observation for the claw402 wallet.
|
||||
func (at *AutoTrader) setAIWalletHealth(balance float64) {
|
||||
status := AIWalletStatusOK
|
||||
switch {
|
||||
case balance <= 0:
|
||||
status = AIWalletStatusEmpty
|
||||
case balance < aiWalletLowThresholdUSDC:
|
||||
status = AIWalletStatusLow
|
||||
}
|
||||
|
||||
at.runtimeHealthMu.Lock()
|
||||
at.aiWalletStatus = status
|
||||
at.aiWalletBalanceUSDC = balance
|
||||
at.aiWalletCheckedAt = time.Now().UTC()
|
||||
at.runtimeHealthMu.Unlock()
|
||||
}
|
||||
|
||||
// markAIWalletHealthUnknown keeps the last observed balance but flags that the
|
||||
// current reading could not be refreshed (e.g. Base RPC unreachable).
|
||||
func (at *AutoTrader) markAIWalletHealthUnknown() {
|
||||
at.runtimeHealthMu.Lock()
|
||||
at.aiWalletStatus = AIWalletStatusUnknown
|
||||
at.aiWalletCheckedAt = time.Now().UTC()
|
||||
at.runtimeHealthMu.Unlock()
|
||||
}
|
||||
|
||||
// markAIWalletEmptyFromPayment records a payment-layer rejection: the wallet
|
||||
// definitively could not cover an AI call.
|
||||
func (at *AutoTrader) markAIWalletEmptyFromPayment(balance float64) {
|
||||
at.runtimeHealthMu.Lock()
|
||||
at.aiWalletStatus = AIWalletStatusEmpty
|
||||
at.aiWalletBalanceUSDC = balance
|
||||
at.aiWalletCheckedAt = time.Now().UTC()
|
||||
at.runtimeHealthMu.Unlock()
|
||||
}
|
||||
|
||||
func (at *AutoTrader) aiWalletHealth() (status string, balance float64, checkedAt time.Time) {
|
||||
at.runtimeHealthMu.RLock()
|
||||
defer at.runtimeHealthMu.RUnlock()
|
||||
return at.aiWalletStatus, at.aiWalletBalanceUSDC, at.aiWalletCheckedAt
|
||||
}
|
||||
200
web/package-lock.json
generated
200
web/package-lock.json
generated
@@ -98,13 +98,13 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -113,9 +113,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
|
||||
"integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -123,21 +123,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
"@babel/helper-compilation-targets": "^7.27.2",
|
||||
"@babel/helper-module-transforms": "^7.28.3",
|
||||
"@babel/helpers": "^7.28.4",
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/traverse": "^7.28.5",
|
||||
"@babel/types": "^7.28.5",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-compilation-targets": "^7.29.7",
|
||||
"@babel/helper-module-transforms": "^7.29.7",
|
||||
"@babel/helpers": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
@@ -164,14 +164,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
|
||||
"integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@babel/types": "^7.28.5",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -181,14 +181,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
|
||||
"integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.27.2",
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
"@babel/compat-data": "^7.29.7",
|
||||
"@babel/helper-validator-option": "^7.29.7",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"semver": "^6.3.1"
|
||||
@@ -208,9 +208,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -218,29 +218,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
|
||||
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.27.1",
|
||||
"@babel/types": "^7.27.1"
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.28.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
|
||||
"integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.27.1",
|
||||
"@babel/traverse": "^7.28.3"
|
||||
"@babel/helper-module-imports": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -260,9 +260,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -270,9 +270,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -280,9 +280,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-option": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -290,27 +290,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
|
||||
"integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/types": "^7.28.4"
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
|
||||
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.5"
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -361,33 +361,33 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/parser": "^7.27.2",
|
||||
"@babel/types": "^7.27.1"
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
|
||||
"integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@babel/template": "^7.27.2",
|
||||
"@babel/types": "^7.28.5",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-globals": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -395,14 +395,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
|
||||
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -4752,16 +4752,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -5102,9 +5102,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -5748,10 +5748,20 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -8522,9 +8532,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"version": "6.4.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
||||
import { LanguageSwitcher } from '../common/LanguageSwitcher'
|
||||
|
||||
export function LoginPage() {
|
||||
const { language } = useLanguage()
|
||||
@@ -64,20 +63,19 @@ export function LoginPage() {
|
||||
|
||||
return (
|
||||
<DeepVoidBackground disableAnimation>
|
||||
<LanguageSwitcher />
|
||||
|
||||
{/* Self-contained centering grid — works regardless of parent flex setup */}
|
||||
<main className="flex-1 grid lg:grid-cols-2">
|
||||
{/* ───────── LEFT: brand panel (desktop only) ───────── */}
|
||||
<section className="hidden lg:flex flex-col justify-between p-12 xl:p-16 relative overflow-hidden">
|
||||
{/* Ambient gold halo */}
|
||||
{/* Ambient vermilion halo */}
|
||||
<div className="absolute -left-32 top-1/3 w-[28rem] h-[28rem] bg-nofx-gold/[0.06] rounded-full blur-3xl pointer-events-none" />
|
||||
<div className="absolute -right-16 bottom-0 w-72 h-72 bg-nofx-accent/[0.04] rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
{/* Brand mark */}
|
||||
<div className="flex items-center gap-3 relative">
|
||||
<img src="/icons/nofx.svg" alt="NOFX" className="w-9 h-9" />
|
||||
<div className="font-mono font-bold text-xl tracking-tight text-white">
|
||||
<div className="font-mono font-bold text-xl tracking-tight text-nofx-text">
|
||||
NOFX<span className="text-nofx-gold">.</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,33 +88,33 @@ export function LoginPage() {
|
||||
Terminal Online
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-4xl xl:text-5xl font-bold tracking-tight text-white leading-[1.05]">
|
||||
<h2 className="text-4xl xl:text-5xl font-bold tracking-tight text-nofx-text leading-[1.05]">
|
||||
{language === 'zh' ? (
|
||||
<>
|
||||
AI 驱动的<br />
|
||||
<span className="bg-gradient-to-r from-nofx-gold to-yellow-300 bg-clip-text text-transparent">
|
||||
多市场交易终端
|
||||
AI-Powered<br />
|
||||
<span className="text-nofx-gold">
|
||||
Multi-Market Trading Terminal
|
||||
</span>
|
||||
</>
|
||||
) : language === 'id' ? (
|
||||
<>
|
||||
Terminal Trading<br />
|
||||
<span className="bg-gradient-to-r from-nofx-gold to-yellow-300 bg-clip-text text-transparent">
|
||||
<span className="text-nofx-gold">
|
||||
Multi-Pasar AI
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
AI-Powered<br />
|
||||
<span className="bg-gradient-to-r from-nofx-gold to-yellow-300 bg-clip-text text-transparent">
|
||||
<span className="text-nofx-gold">
|
||||
Trading Terminal
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</h2>
|
||||
<p className="mt-5 text-zinc-400 text-base leading-relaxed max-w-md">
|
||||
<p className="mt-5 text-nofx-text-muted text-base leading-relaxed max-w-md">
|
||||
{language === 'zh'
|
||||
? '一键接入 Hyperliquid、OKX、Aster 等 10+ 交易所与 7 个 LLM 模型, 用自然语言部署 24/7 自动化策略.'
|
||||
? 'Plug into 10+ exchanges including Hyperliquid, OKX, Aster, and 7 LLM models. Deploy 24/7 automated strategies with natural language.'
|
||||
: language === 'id'
|
||||
? 'Hubungkan ke 10+ bursa termasuk Hyperliquid, OKX, Aster dan 7 model LLM. Terapkan strategi otomatis 24/7 dengan bahasa alami.'
|
||||
: 'Plug into 10+ exchanges including Hyperliquid, OKX, Aster, and 7 LLM models. Deploy 24/7 automated strategies with natural language.'}
|
||||
@@ -129,7 +127,7 @@ export function LoginPage() {
|
||||
value="10+"
|
||||
label={
|
||||
language === 'zh'
|
||||
? '交易所'
|
||||
? 'Exchanges'
|
||||
: language === 'id'
|
||||
? 'Bursa'
|
||||
: 'Exchanges'
|
||||
@@ -139,7 +137,7 @@ export function LoginPage() {
|
||||
value="7"
|
||||
label={
|
||||
language === 'zh'
|
||||
? 'AI 模型'
|
||||
? 'AI Models'
|
||||
: language === 'id'
|
||||
? 'Model AI'
|
||||
: 'AI Models'
|
||||
@@ -149,7 +147,7 @@ export function LoginPage() {
|
||||
value="24/7"
|
||||
label={
|
||||
language === 'zh'
|
||||
? '全天候'
|
||||
? 'Always On'
|
||||
: language === 'id'
|
||||
? 'Sepanjang Waktu'
|
||||
: 'Always On'
|
||||
@@ -164,19 +162,19 @@ export function LoginPage() {
|
||||
{/* Mobile brand */}
|
||||
<div className="lg:hidden flex flex-col items-center gap-3 mb-10">
|
||||
<img src="/icons/nofx.svg" alt="NOFX" className="w-12 h-12" />
|
||||
<div className="font-mono font-bold text-lg tracking-tight text-white">
|
||||
<div className="font-mono font-bold text-lg tracking-tight text-nofx-text">
|
||||
NOFX<span className="text-nofx-gold">.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form header */}
|
||||
<div className="mb-7">
|
||||
<h1 className="text-[26px] sm:text-3xl font-bold tracking-tight text-white">
|
||||
<h1 className="text-[26px] sm:text-3xl font-bold tracking-tight text-nofx-text">
|
||||
{t('signIn', language)}
|
||||
</h1>
|
||||
<p className="mt-1.5 text-sm text-zinc-500">
|
||||
<p className="mt-1.5 text-sm text-nofx-text-muted">
|
||||
{language === 'zh'
|
||||
? '使用您的邮箱继续'
|
||||
? 'Continue with your email'
|
||||
: language === 'id'
|
||||
? 'Lanjutkan dengan email Anda'
|
||||
: 'Continue with your email'}
|
||||
@@ -187,14 +185,14 @@ export function LoginPage() {
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-[10.5px] font-medium uppercase tracking-[0.14em] text-zinc-500 mb-2">
|
||||
<label className="block text-[10.5px] font-medium uppercase tracking-[0.14em] text-nofx-text-muted mb-2">
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-zinc-900/60 border border-white/[0.08] rounded-lg px-4 py-[11px] text-[14px] text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/50 focus:bg-zinc-900 focus:ring-2 focus:ring-nofx-gold/20 transition-all"
|
||||
className="w-full bg-nofx-bg-lighter border border-[rgba(26,24,19,0.14)] rounded-lg px-4 py-[11px] text-[14px] text-nofx-text placeholder-nofx-text-muted focus:outline-none focus:border-nofx-gold/50 focus:bg-nofx-bg-lighter focus:ring-2 focus:ring-nofx-gold/20 transition-all"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autoFocus
|
||||
@@ -205,13 +203,13 @@ export function LoginPage() {
|
||||
{/* Password */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-[10.5px] font-medium uppercase tracking-[0.14em] text-zinc-500">
|
||||
<label className="text-[10.5px] font-medium uppercase tracking-[0.14em] text-nofx-text-muted">
|
||||
{t('password', language)}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/reset-password')}
|
||||
className="text-xs text-zinc-500 hover:text-nofx-gold transition-colors"
|
||||
className="text-xs text-nofx-text-muted hover:text-nofx-gold transition-colors"
|
||||
>
|
||||
{t('forgotPassword', language)}
|
||||
</button>
|
||||
@@ -221,7 +219,7 @@ export function LoginPage() {
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-zinc-900/60 border border-white/[0.08] rounded-lg px-4 py-[11px] pr-11 text-[14px] text-white placeholder-zinc-600 focus:outline-none focus:border-nofx-gold/50 focus:bg-zinc-900 focus:ring-2 focus:ring-nofx-gold/20 transition-all"
|
||||
className="w-full bg-nofx-bg-lighter border border-[rgba(26,24,19,0.14)] rounded-lg px-4 py-[11px] pr-11 text-[14px] text-nofx-text placeholder-nofx-text-muted focus:outline-none focus:border-nofx-gold/50 focus:bg-nofx-bg-lighter focus:ring-2 focus:ring-nofx-gold/20 transition-all"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
@@ -229,7 +227,7 @@ export function LoginPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-nofx-text-muted hover:text-nofx-text transition-colors"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
@@ -239,8 +237,8 @@ export function LoginPage() {
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-red-500/25 bg-red-500/[0.08] px-3 py-2.5 text-xs text-red-300">
|
||||
<span className="text-red-400 font-bold mt-px">!</span>
|
||||
<div className="flex items-start gap-2 rounded-lg border border-nofx-danger/25 bg-nofx-danger/[0.08] px-3 py-2.5 text-xs text-nofx-danger">
|
||||
<span className="text-nofx-danger font-bold mt-px">!</span>
|
||||
<span className="leading-relaxed">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -249,7 +247,7 @@ export function LoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group mt-2 flex w-full items-center justify-center gap-2 rounded-lg bg-nofx-gold py-[11px] text-sm font-semibold text-black shadow-lg shadow-nofx-gold/10 transition-all hover:bg-yellow-400 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="group mt-2 flex w-full items-center justify-center gap-2 rounded-lg bg-nofx-gold py-[11px] text-sm font-semibold text-nofx-bg transition-all hover:bg-nofx-gold-highlight active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
@@ -269,12 +267,12 @@ export function LoginPage() {
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 pt-5 border-t border-white/[0.06] flex items-center justify-between text-[11px]">
|
||||
<span className="font-mono text-zinc-600">v1.0</span>
|
||||
<div className="mt-8 pt-5 border-t border-[rgba(26,24,19,0.14)] flex items-center justify-between text-[11px]">
|
||||
<span className="font-mono text-nofx-text-muted">v1.0</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetAccount}
|
||||
className="text-zinc-600 transition-colors hover:text-red-400"
|
||||
className="text-nofx-text-muted transition-colors hover:text-nofx-danger"
|
||||
>
|
||||
{t('forgotAccount', language)}
|
||||
</button>
|
||||
@@ -289,10 +287,10 @@ export function LoginPage() {
|
||||
function Stat({ value, label }: { value: string; label: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="font-mono text-2xl xl:text-3xl font-bold text-white">
|
||||
<div className="font-mono text-2xl xl:text-3xl font-bold text-nofx-text">
|
||||
{value}
|
||||
</div>
|
||||
<div className="mt-1 text-[10.5px] uppercase tracking-[0.14em] text-zinc-500">
|
||||
<div className="mt-1 text-[10.5px] uppercase tracking-[0.14em] text-nofx-text-muted">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,11 +46,11 @@ export function LoginRequiredOverlay({
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||
transition={{ type: 'spring', damping: 20, stiffness: 300 }}
|
||||
className="relative max-w-md w-full overflow-hidden bg-nofx-bg border border-nofx-gold/30 shadow-neon rounded-sm group font-mono"
|
||||
className="relative max-w-md w-full overflow-hidden bg-nofx-bg-lighter border border-nofx-gold/30 shadow-lg rounded-sm group font-mono"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Terminal Window Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-nofx-bg-lighter border-b border-nofx-gold/20">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-nofx-bg-deeper border-b border-nofx-gold/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal size={12} className="text-nofx-gold" />
|
||||
<span className="text-[10px] text-nofx-text-muted uppercase tracking-wider">
|
||||
@@ -74,8 +74,7 @@ export function LoginRequiredOverlay({
|
||||
{/* Flashing Access Denied */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-red-500/20 blur-xl animate-pulse"></div>
|
||||
<div className="bg-nofx-bg border border-red-500/50 text-red-500 px-4 py-2 flex items-center gap-3 shadow-[0_0_15px_rgba(239,68,68,0.2)]">
|
||||
<div className="bg-nofx-bg-lighter border border-nofx-danger/50 text-nofx-danger px-4 py-2 flex items-center gap-3">
|
||||
<AlertTriangle size={18} className="animate-pulse" />
|
||||
<span className="font-bold tracking-widest text-sm uppercase">
|
||||
{tr('accessDenied')}
|
||||
@@ -87,7 +86,7 @@ export function LoginRequiredOverlay({
|
||||
{/* Terminal Text */}
|
||||
<div className="space-y-4 mb-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold text-white uppercase tracking-wider mb-2">
|
||||
<h2 className="text-xl font-bold text-nofx-text uppercase tracking-wider mb-2">
|
||||
{tr('title')}
|
||||
</h2>
|
||||
<p className="text-nofx-gold text-xs uppercase tracking-widest border-b border-nofx-gold/20 pb-4 inline-block">
|
||||
@@ -95,9 +94,9 @@ export function LoginRequiredOverlay({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-nofx-bg-lighter border-l-2 border-nofx-gold/20 p-3 my-4">
|
||||
<div className="bg-nofx-bg-deeper border-l-2 border-nofx-gold/20 p-3 my-4">
|
||||
<p className="text-xs text-nofx-text-muted leading-relaxed font-mono">
|
||||
<span className="text-green-500 mr-2">$</span>
|
||||
<span className="text-nofx-success mr-2">$</span>
|
||||
{tr('description')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -118,7 +117,7 @@ export function LoginRequiredOverlay({
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
to="/login"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-nofx-gold text-black font-bold text-xs uppercase tracking-widest hover:bg-yellow-400 transition-all shadow-neon hover:shadow-[0_0_25px_rgba(240,185,11,0.4)] group"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-nofx-gold text-nofx-bg font-bold text-xs uppercase tracking-widest hover:bg-nofx-gold-highlight transition-all group"
|
||||
>
|
||||
<LogIn size={14} />
|
||||
<span>{tr('loginButton')}</span>
|
||||
@@ -129,7 +128,7 @@ export function LoginRequiredOverlay({
|
||||
|
||||
<Link
|
||||
to="/register"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-transparent border border-nofx-gold/20 text-nofx-text-muted hover:text-white hover:border-nofx-gold font-bold text-xs uppercase tracking-widest transition-all hover:bg-nofx-gold/10"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-transparent border border-nofx-gold/20 text-nofx-text-muted hover:text-nofx-text hover:border-nofx-gold font-bold text-xs uppercase tracking-widest transition-all hover:bg-nofx-gold/10"
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
<span>{tr('registerButton')}</span>
|
||||
@@ -139,7 +138,7 @@ export function LoginRequiredOverlay({
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[10px] text-nofx-text-muted hover:text-nofx-danger uppercase tracking-widest hover:underline decoration-red-500/30"
|
||||
className="text-[10px] text-nofx-text-muted hover:text-nofx-danger uppercase tracking-widest hover:underline decoration-nofx-danger/30"
|
||||
>
|
||||
[ {tr('abort')} ]
|
||||
</button>
|
||||
|
||||
@@ -21,25 +21,25 @@ export function OnboardingModeSelector({
|
||||
}> = [
|
||||
{
|
||||
id: 'beginner',
|
||||
title: isZh ? '新手模式' : 'Beginner Mode',
|
||||
badge: isZh ? '推荐' : 'Recommended',
|
||||
title: isZh ? 'Beginner Mode' : 'Beginner Mode',
|
||||
badge: isZh ? 'Recommended' : 'Recommended',
|
||||
description: isZh
|
||||
? '自动生成 Base 钱包,默认接入 Claw402 + GLM,最快完成首次启动。'
|
||||
? 'Generate a Base wallet automatically and start with Claw402 + GLM by default.'
|
||||
: 'Generate a Base wallet automatically and start with Claw402 + GLM by default.',
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
title: isZh ? '老手模式' : 'Advanced Mode',
|
||||
title: isZh ? 'Advanced Mode' : 'Advanced Mode',
|
||||
description: isZh
|
||||
? '保持现在的完整配置流程,你自己决定模型、钱包和交易所。'
|
||||
? 'Keep the full manual flow and configure models, wallets, and exchanges yourself.'
|
||||
: 'Keep the full manual flow and configure models, wallets, and exchanges yourself.',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-zinc-400">
|
||||
{isZh ? '使用模式' : 'Experience'}
|
||||
<div className="text-xs font-medium text-nofx-text-muted">
|
||||
{isZh ? 'Experience' : 'Experience'}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{options.map((option) => {
|
||||
@@ -51,19 +51,19 @@ export function OnboardingModeSelector({
|
||||
onClick={() => onChange(option.id)}
|
||||
className={`w-full rounded-xl border px-4 py-3 text-left transition-all ${
|
||||
selected
|
||||
? 'border-nofx-gold/60 bg-nofx-gold/10 shadow-[0_0_0_1px_rgba(240,185,11,0.15)]'
|
||||
: 'border-zinc-800 bg-zinc-950/60 hover:border-zinc-700'
|
||||
? 'border-nofx-gold/60 bg-nofx-gold/10'
|
||||
: 'border-[rgba(26,24,19,0.14)] bg-nofx-bg-lighter hover:border-nofx-gold/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-nofx-text">
|
||||
<span>{option.title}</span>
|
||||
{option.badge ? (
|
||||
<span className="rounded-full bg-nofx-gold px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-black">
|
||||
<span className="rounded-full bg-nofx-gold px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-nofx-bg">
|
||||
{option.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-zinc-400">
|
||||
<p className="mt-1 text-xs leading-5 text-nofx-text-muted">
|
||||
{option.description}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
/**
|
||||
* PR #XXX 测试: 修复密码校验不一致的问题
|
||||
* PR #XXX test: fix inconsistent password validation
|
||||
*
|
||||
* 问题:RegisterPage 中存在两处密码校验逻辑:
|
||||
* 1. PasswordChecklist 组件提供的可视化校验
|
||||
* 2. 自定义的 isStrongPassword 函数
|
||||
* 这导致校验规则可能不一致
|
||||
* Problem: RegisterPage had two password validation paths:
|
||||
* 1. Visual validation provided by the PasswordChecklist component
|
||||
* 2. A custom isStrongPassword function
|
||||
* This could cause the validation rules to diverge.
|
||||
*
|
||||
* 修复:移除重复的 isStrongPassword 函数,统一使用 PasswordChecklist 的校验结果
|
||||
* Fix: remove the duplicate isStrongPassword function and rely solely on the PasswordChecklist result.
|
||||
*
|
||||
* 本测试专注于验证密码校验逻辑的一致性,确保:
|
||||
* 1. 移除了重复的 isStrongPassword 函数
|
||||
* 2. 使用统一的 PasswordChecklist 校验
|
||||
* 3. 特殊字符规则在正常显示和错误提示中保持一致
|
||||
* This test focuses on verifying the consistency of the password validation logic, ensuring:
|
||||
* 1. The duplicate isStrongPassword function is removed
|
||||
* 2. A single PasswordChecklist validation is used
|
||||
* 3. The special character rule stays consistent between normal display and error messages
|
||||
*/
|
||||
|
||||
describe('RegisterPage - Password Validation Consistency (Logic Tests)', () => {
|
||||
/**
|
||||
* 测试密码校验规则逻辑
|
||||
* 这些测试验证密码校验的核心逻辑,与 PasswordChecklist 组件的规则一致
|
||||
* Test the password validation rule logic
|
||||
* These tests verify the core validation logic, consistent with the PasswordChecklist component rules
|
||||
*/
|
||||
describe('password validation rules', () => {
|
||||
it('should validate minimum 8 characters', () => {
|
||||
@@ -57,7 +57,7 @@ describe('RegisterPage - Password Validation Consistency (Logic Tests)', () => {
|
||||
})
|
||||
|
||||
it('should require special character from allowed set', () => {
|
||||
// 根据 RegisterPage.tsx 中的设置,特殊字符正则为 /[@#$%!&*?]/
|
||||
// Per the RegisterPage.tsx config, the special character regex is /[@#$%!&*?]/
|
||||
const hasSpecialChar = (pwd: string) => /[@#$%!&*?]/.test(pwd)
|
||||
|
||||
expect(hasSpecialChar('NoSpecial123')).toBe(false)
|
||||
@@ -70,7 +70,7 @@ describe('RegisterPage - Password Validation Consistency (Logic Tests)', () => {
|
||||
expect(hasSpecialChar('HasStar123*')).toBe(true)
|
||||
expect(hasSpecialChar('HasQuestion123?')).toBe(true)
|
||||
|
||||
// 不在允许列表中的特殊字符应该不通过
|
||||
// Special characters not in the allowed list should fail
|
||||
expect(hasSpecialChar('HasCaret123^')).toBe(false)
|
||||
expect(hasSpecialChar('HasTilde123~')).toBe(false)
|
||||
})
|
||||
@@ -86,8 +86,8 @@ describe('RegisterPage - Password Validation Consistency (Logic Tests)', () => {
|
||||
})
|
||||
|
||||
/**
|
||||
* 测试完整的密码强度校验
|
||||
* 模拟 PasswordChecklist 的完整校验逻辑
|
||||
* Test the complete password strength validation
|
||||
* Simulates the full PasswordChecklist validation logic
|
||||
*/
|
||||
describe('complete password strength validation', () => {
|
||||
const validatePassword = (
|
||||
@@ -193,22 +193,22 @@ describe('RegisterPage - Password Validation Consistency (Logic Tests)', () => {
|
||||
})
|
||||
|
||||
/**
|
||||
* 测试特殊字符一致性
|
||||
* 确保在 RegisterPage 的正常显示(第 229-251 行)和错误提示(第 300-323 行)中
|
||||
* 使用相同的特殊字符正则 /[@#$%!&*?]/
|
||||
* Test special character consistency
|
||||
* Ensures the normal display (lines 229-251) and error messages (lines 300-323) in RegisterPage
|
||||
* use the same special character regex /[@#$%!&*?]/
|
||||
*/
|
||||
describe('special character consistency', () => {
|
||||
it('should use consistent special character regex across all validations', () => {
|
||||
// RegisterPage 中两处 PasswordChecklist 都应该使用相同的 specialCharsRegex
|
||||
// Both PasswordChecklist instances in RegisterPage should use the same specialCharsRegex
|
||||
const specialCharsRegex = /[@#$%!&*?]/
|
||||
|
||||
// 测试允许的特殊字符
|
||||
// Test the allowed special characters
|
||||
const validSpecialChars = ['@', '#', '$', '%', '!', '&', '*', '?']
|
||||
validSpecialChars.forEach((char) => {
|
||||
expect(specialCharsRegex.test(char)).toBe(true)
|
||||
})
|
||||
|
||||
// 测试不允许的特殊字符
|
||||
// Test the disallowed special characters
|
||||
const invalidSpecialChars = ['^', '~', '`', '(', ')', '-', '_', '=', '+']
|
||||
invalidSpecialChars.forEach((char) => {
|
||||
expect(specialCharsRegex.test(char)).toBe(false)
|
||||
@@ -254,7 +254,7 @@ describe('RegisterPage - Password Validation Consistency (Logic Tests)', () => {
|
||||
})
|
||||
|
||||
/**
|
||||
* 测试边界情况
|
||||
* Test edge cases
|
||||
*/
|
||||
describe('edge cases', () => {
|
||||
const validatePassword = (pwd: string, confirmPwd: string): boolean => {
|
||||
@@ -311,16 +311,16 @@ describe('RegisterPage - Password Validation Consistency (Logic Tests)', () => {
|
||||
})
|
||||
|
||||
/**
|
||||
* 测试重构后的一致性
|
||||
* 确保移除 isStrongPassword 函数后,所有校验都通过 PasswordChecklist
|
||||
* Test consistency after refactoring
|
||||
* Ensures that after removing the isStrongPassword function, all validation goes through PasswordChecklist
|
||||
*/
|
||||
describe('refactoring consistency verification', () => {
|
||||
it('should have removed duplicate isStrongPassword function', () => {
|
||||
// 这个测试验证重构的意图:
|
||||
// 在重构之前,存在一个 isStrongPassword 函数
|
||||
// 重构后应该移除该函数,只使用 PasswordChecklist 的校验
|
||||
// This test verifies the intent of the refactor:
|
||||
// Before the refactor there was an isStrongPassword function
|
||||
// After the refactor it should be removed, using only PasswordChecklist validation
|
||||
|
||||
// 我们通过模拟 PasswordChecklist 的逻辑来验证一致性
|
||||
// We verify consistency by simulating the PasswordChecklist logic
|
||||
const passwordChecklistValidation = (pwd: string, confirm: string) => {
|
||||
return {
|
||||
minLength: pwd.length >= 8,
|
||||
@@ -332,7 +332,7 @@ describe('RegisterPage - Password Validation Consistency (Logic Tests)', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 测试几个密码
|
||||
// Test a few passwords
|
||||
const testCases = [
|
||||
{ pwd: 'Weak', confirm: 'Weak', shouldPass: false },
|
||||
{ pwd: 'StrongPass123!', confirm: 'StrongPass123!', shouldPass: true },
|
||||
@@ -351,7 +351,7 @@ describe('RegisterPage - Password Validation Consistency (Logic Tests)', () => {
|
||||
})
|
||||
|
||||
it('should use consistent validation logic across the component', () => {
|
||||
// 验证校验逻辑的一致性
|
||||
// Verify the consistency of the validation logic
|
||||
const validation1 = {
|
||||
minLength: 8,
|
||||
requireCapital: true,
|
||||
@@ -361,7 +361,7 @@ describe('RegisterPage - Password Validation Consistency (Logic Tests)', () => {
|
||||
specialCharsRegex: /[@#$%!&*?]/,
|
||||
}
|
||||
|
||||
// 在 RegisterPage 的正常显示和错误提示中应该使用相同的配置
|
||||
// The normal display and error messages in RegisterPage should use the same config
|
||||
const validation2 = {
|
||||
minLength: 8,
|
||||
requireCapital: true,
|
||||
|
||||
@@ -57,7 +57,7 @@ export function RegisterPage() {
|
||||
}
|
||||
|
||||
if (betaMode && !betaCode.trim()) {
|
||||
setError('内测期间,注册需要提供内测码')
|
||||
setError('A beta code is required to register during the closed beta')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -123,9 +123,9 @@ export function RegisterPage() {
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="flex items-center gap-2 text-zinc-500 hover:text-white transition-colors group px-3 py-1.5 rounded border border-transparent hover:border-zinc-700 bg-black/20 backdrop-blur-sm"
|
||||
className="flex items-center gap-2 text-nofx-text-muted hover:text-nofx-text transition-colors group px-3 py-1.5 rounded border border-transparent hover:border-[rgba(26,24,19,0.14)] bg-nofx-bg-deeper backdrop-blur-sm"
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 group-hover:animate-pulse"></div>
|
||||
<div className="w-2 h-2 rounded-full bg-nofx-danger group-hover:animate-pulse"></div>
|
||||
<span className="text-xs font-mono uppercase tracking-widest">
|
||||
< ABORT_REGISTRATION
|
||||
</span>
|
||||
@@ -135,7 +135,6 @@ export function RegisterPage() {
|
||||
<div className="mb-8 text-center">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-2 bg-nofx-gold/20 rounded-full blur-xl animate-pulse"></div>
|
||||
<img
|
||||
src="/icons/nofx.svg"
|
||||
alt="NoFx Logo"
|
||||
@@ -143,56 +142,55 @@ export function RegisterPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tighter text-white uppercase mb-2">
|
||||
<span className="text-nofx-gold">NEW_USER</span> ONBOARDING
|
||||
<h1 className="text-3xl font-bold tracking-tighter text-nofx-text uppercase mb-2">
|
||||
<span className="text-nofx-gold">CREATE</span> YOUR ACCOUNT
|
||||
</h1>
|
||||
<p className="text-zinc-500 text-xs tracking-[0.2em] uppercase">
|
||||
Initializing Registration Sequence...
|
||||
<p className="text-nofx-text-muted text-sm">
|
||||
This account owns your NOFX instance. Next step: a guided launch —
|
||||
about $13 and five minutes to your first AI trade.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900/40 backdrop-blur-md border border-zinc-800 rounded-lg overflow-hidden shadow-2xl relative group">
|
||||
<div className="absolute inset-0 bg-zinc-900/50 opacity-0 group-hover:opacity-100 transition duration-700 pointer-events-none"></div>
|
||||
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900/80 border-b border-zinc-800">
|
||||
<div className="bg-nofx-bg-lighter backdrop-blur-md border border-[rgba(26,24,19,0.14)] rounded-lg overflow-hidden shadow-lg relative group">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-nofx-bg-deeper border-b border-[rgba(26,24,19,0.14)]">
|
||||
<div className="flex gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full bg-red-500/50 hover:bg-red-500 cursor-pointer transition-colors"
|
||||
className="w-2.5 h-2.5 rounded-full bg-nofx-danger/50 hover:bg-nofx-danger cursor-pointer transition-colors"
|
||||
onClick={() => navigate('/')}
|
||||
title="Close / Return Home"
|
||||
></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500/50"></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-green-500/50"></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-nofx-gold/50"></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-nofx-success/50"></div>
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-600 font-mono flex items-center gap-1">
|
||||
<span className="text-emerald-500">➜</span> setup_account.sh
|
||||
<div className="text-[10px] text-nofx-text-muted font-mono flex items-center gap-1">
|
||||
<span className="text-nofx-success">➜</span> setup_account.sh
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 md:p-8 relative">
|
||||
<div className="mb-6 font-mono text-xs space-y-1 text-zinc-500 border-b border-zinc-800/50 pb-4">
|
||||
<div className="mb-6 font-mono text-xs space-y-1 text-nofx-text-muted border-b border-[rgba(26,24,19,0.14)] pb-4">
|
||||
<div className="flex gap-2">
|
||||
<span className="text-emerald-500">➜</span>
|
||||
<span className="text-nofx-success">➜</span>
|
||||
<span>
|
||||
System Check: <span className="text-emerald-500">READY</span>
|
||||
System Check: <span className="text-nofx-success">READY</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-emerald-500">➜</span>
|
||||
<span className="text-nofx-success">➜</span>
|
||||
<span>Mode: {betaMode ? 'CLOSED_BETA CA1' : 'PUBLIC'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleRegister} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">
|
||||
<label className="block text-xs uppercase tracking-wider text-nofx-text-muted mb-1.5 ml-1 font-bold">
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono"
|
||||
className="w-full bg-nofx-bg border border-[rgba(26,24,19,0.14)] rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-nofx-text-muted text-nofx-text font-mono"
|
||||
placeholder="user@nofx.os"
|
||||
required
|
||||
/>
|
||||
@@ -200,7 +198,7 @@ export function RegisterPage() {
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">
|
||||
<label className="block text-xs uppercase tracking-wider text-nofx-text-muted mb-1.5 ml-1 font-bold">
|
||||
{t('password', language)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -208,14 +206,14 @@ export function RegisterPage() {
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono pr-10"
|
||||
className="w-full bg-nofx-bg border border-[rgba(26,24,19,0.14)] rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-nofx-text-muted text-nofx-text font-mono pr-10"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400 transition-colors"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-nofx-text-muted hover:text-nofx-text transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
@@ -223,7 +221,7 @@ export function RegisterPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider text-zinc-500 mb-1.5 ml-1 font-bold">
|
||||
<label className="block text-xs uppercase tracking-wider text-nofx-text-muted mb-1.5 ml-1 font-bold">
|
||||
{t('confirmPassword', language)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -231,7 +229,7 @@ export function RegisterPage() {
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono pr-10"
|
||||
className="w-full bg-nofx-bg border border-[rgba(26,24,19,0.14)] rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-nofx-text-muted text-nofx-text font-mono pr-10"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
@@ -240,7 +238,7 @@ export function RegisterPage() {
|
||||
onClick={() =>
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-600 hover:text-zinc-400 transition-colors"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-nofx-text-muted hover:text-nofx-text transition-colors"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff size={16} />
|
||||
@@ -252,12 +250,12 @@ export function RegisterPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900/50 p-3 rounded border border-zinc-800/50">
|
||||
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-2 font-bold flex items-center gap-2">
|
||||
<div className="w-1 h-1 rounded-full bg-zinc-500"></div>
|
||||
<div className="bg-nofx-bg-deeper p-3 rounded border border-[rgba(26,24,19,0.14)]">
|
||||
<div className="text-[10px] uppercase tracking-wider text-nofx-text-muted mb-2 font-bold flex items-center gap-2">
|
||||
<div className="w-1 h-1 rounded-full bg-nofx-text-muted"></div>
|
||||
Password Strength Protocol
|
||||
</div>
|
||||
<div className="text-xs font-mono text-zinc-400">
|
||||
<div className="text-xs font-mono text-nofx-text-muted">
|
||||
<PasswordChecklist
|
||||
rules={[
|
||||
'minLength',
|
||||
@@ -298,19 +296,19 @@ export function RegisterPage() {
|
||||
e.target.value.replace(/[^a-z0-9]/gi, '').toLowerCase()
|
||||
)
|
||||
}
|
||||
className="w-full bg-black/50 border border-zinc-700 rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-zinc-800 text-white font-mono tracking-widest"
|
||||
className="w-full bg-nofx-bg border border-[rgba(26,24,19,0.14)] rounded px-4 py-3 text-sm focus:border-nofx-gold focus:ring-1 focus:ring-nofx-gold/50 outline-none transition-all placeholder-nofx-text-muted text-nofx-text font-mono tracking-widest"
|
||||
placeholder="XXXXXX"
|
||||
maxLength={6}
|
||||
required={betaMode}
|
||||
/>
|
||||
<p className="text-[10px] text-zinc-600 font-mono mt-1 ml-1">
|
||||
<p className="text-[10px] text-nofx-text-muted font-mono mt-1 ml-1">
|
||||
* CASE SENSITIVE ALPHANUMERIC
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs bg-red-500/10 border border-red-500/30 text-red-500 px-3 py-2 rounded font-mono">
|
||||
<div className="text-xs bg-nofx-danger/10 border border-nofx-danger/30 text-nofx-danger px-3 py-2 rounded font-mono">
|
||||
[REGISTRATION_ERROR]: {error}
|
||||
</div>
|
||||
)}
|
||||
@@ -320,7 +318,7 @@ export function RegisterPage() {
|
||||
disabled={
|
||||
loading || (betaMode && !betaCode.trim()) || !passwordValid
|
||||
}
|
||||
className="w-full bg-nofx-gold text-black font-bold py-3 px-4 rounded text-sm tracking-wide uppercase hover:bg-yellow-400 transition-all transform active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed font-mono shadow-[0_0_15px_rgba(255,215,0,0.1)] hover:shadow-[0_0_25px_rgba(255,215,0,0.25)] flex items-center justify-center gap-2 group mt-4"
|
||||
className="w-full bg-nofx-gold text-nofx-bg font-bold py-3 px-4 rounded text-sm tracking-wide uppercase hover:bg-nofx-gold-highlight transition-all transform active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed font-mono flex items-center justify-center gap-2 group mt-4"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="animate-pulse">INITIALIZING...</span>
|
||||
@@ -336,25 +334,25 @@ export function RegisterPage() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="bg-zinc-900/50 p-3 flex justify-between items-center text-[10px] font-mono text-zinc-600 border-t border-zinc-800">
|
||||
<div className="bg-nofx-bg-deeper p-3 flex justify-between items-center text-[10px] font-mono text-nofx-text-muted border-t border-[rgba(26,24,19,0.14)]">
|
||||
<div>ENCRYPTION: AES-256</div>
|
||||
<div>SECURE_REGISTRY</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-8 space-y-4">
|
||||
<p className="text-xs font-mono text-zinc-500">
|
||||
<p className="text-xs font-mono text-nofx-text-muted">
|
||||
EXISTING_OPERATOR?{' '}
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="text-nofx-gold hover:underline hover:text-yellow-300 transition-colors ml-1 uppercase"
|
||||
className="text-nofx-gold hover:underline hover:text-nofx-gold-highlight transition-colors ml-1 uppercase"
|
||||
>
|
||||
ACCESS TERMINAL
|
||||
</button>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="text-[10px] text-zinc-600 hover:text-red-500 transition-colors uppercase tracking-widest hover:underline decoration-red-500/30 font-mono"
|
||||
className="text-[10px] text-nofx-text-muted hover:text-nofx-danger transition-colors uppercase tracking-widest hover:underline decoration-nofx-danger/30 font-mono"
|
||||
>
|
||||
[ ABORT_REGISTRATION_RETURN_HOME ]
|
||||
</button>
|
||||
|
||||
@@ -89,15 +89,15 @@ describe('RegistrationDisabled Component', () => {
|
||||
it('should have correct background color', () => {
|
||||
const { container } = renderComponent()
|
||||
const mainDiv = container.firstChild as HTMLElement
|
||||
// Browser converts hex to rgb
|
||||
expect(mainDiv.style.background).toMatch(/rgb\(11,\s*14,\s*17\)|#0B0E11/i)
|
||||
// Browser converts hex to rgb (cream paper theme)
|
||||
expect(mainDiv.style.background).toMatch(/rgb\(241,\s*236,\s*226\)|#F1ECE2/i)
|
||||
})
|
||||
|
||||
it('should have correct text color', () => {
|
||||
const { container } = renderComponent()
|
||||
const mainDiv = container.firstChild as HTMLElement
|
||||
// Browser converts hex to rgb
|
||||
expect(mainDiv.style.color).toMatch(/rgb\(234,\s*236,\s*239\)|#EAECEF/i)
|
||||
// Browser converts hex to rgb (ink text)
|
||||
expect(mainDiv.style.color).toMatch(/rgb\(26,\s*24,\s*19\)|#1A1813/i)
|
||||
})
|
||||
|
||||
it('should have centered layout', () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ export function RegistrationDisabled() {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center"
|
||||
style={{ background: '#0B0E11', color: '#EAECEF' }}
|
||||
style={{ background: '#F1ECE2', color: '#1A1813' }}
|
||||
>
|
||||
<div className="text-center max-w-md px-6">
|
||||
<img
|
||||
@@ -24,12 +24,12 @@ export function RegistrationDisabled() {
|
||||
<h1 className="text-2xl font-semibold mb-3">
|
||||
{t('registrationClosed', language)}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
<p className="text-sm text-nofx-text-muted">
|
||||
{t('registrationClosedMessage', language)}
|
||||
</p>
|
||||
<button
|
||||
className="mt-6 px-4 py-2 rounded text-sm font-semibold transition-colors hover:opacity-90"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
style={{ background: '#E0483B', color: '#F1ECE2' }}
|
||||
onClick={handleBackToLogin}
|
||||
>
|
||||
{t('backToLogin', language)}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ResetPasswordPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: '#0B0E11' }}>
|
||||
<div className="min-h-screen" style={{ background: '#F1ECE2' }}>
|
||||
<Header simple />
|
||||
|
||||
<div
|
||||
@@ -36,8 +36,8 @@ export function ResetPasswordPage() {
|
||||
{/* Back to Login */}
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="flex items-center gap-2 mb-6 text-sm hover:text-[#F0B90B] transition-colors"
|
||||
style={{ color: '#848E9C' }}
|
||||
className="flex items-center gap-2 mb-6 text-sm hover:text-[#E0483B] transition-colors"
|
||||
style={{ color: '#8A8478' }}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t('backToLogin', language)}
|
||||
@@ -47,11 +47,11 @@ export function ResetPasswordPage() {
|
||||
<div className="text-center mb-8">
|
||||
<div
|
||||
className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.1)' }}
|
||||
style={{ background: 'rgba(224, 72, 59, 0.1)' }}
|
||||
>
|
||||
<KeyRound className="w-8 h-8" style={{ color: '#F0B90B' }} />
|
||||
<KeyRound className="w-8 h-8" style={{ color: '#E0483B' }} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
<h1 className="text-2xl font-bold" style={{ color: '#1A1813' }}>
|
||||
{t('resetPasswordTitle', language)}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -59,22 +59,22 @@ export function ResetPasswordPage() {
|
||||
{/* CLI recovery instructions */}
|
||||
<div
|
||||
className="rounded-lg p-6"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
|
||||
style={{ background: '#F7F4EC', border: '1px solid rgba(26,24,19,0.14)' }}
|
||||
>
|
||||
<p
|
||||
className="text-sm leading-relaxed mb-4"
|
||||
style={{ color: '#EAECEF' }}
|
||||
style={{ color: '#1A1813' }}
|
||||
>
|
||||
{t('resetPasswordCliIntro', language)}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 rounded px-3 py-3 font-mono text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
style={{ background: '#E8E2D5', border: '1px solid rgba(26,24,19,0.14)' }}
|
||||
>
|
||||
<code
|
||||
className="break-all"
|
||||
style={{ color: '#F0B90B' }}
|
||||
style={{ color: '#E0483B' }}
|
||||
>
|
||||
{RESET_PASSWORD_COMMAND}
|
||||
</code>
|
||||
@@ -82,11 +82,11 @@ export function ResetPasswordPage() {
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 btn-icon"
|
||||
style={{ color: '#848E9C' }}
|
||||
style={{ color: '#8A8478' }}
|
||||
aria-label={t('copy', language)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4" style={{ color: '#0ECB81' }} />
|
||||
<Check className="w-4 h-4" style={{ color: '#2E8B57' }} />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
@@ -95,7 +95,7 @@ export function ResetPasswordPage() {
|
||||
|
||||
<p
|
||||
className="text-xs leading-relaxed mt-4"
|
||||
style={{ color: '#848E9C' }}
|
||||
style={{ color: '#8A8478' }}
|
||||
>
|
||||
{t('resetPasswordCliSecurityNote', language)}
|
||||
</p>
|
||||
|
||||
@@ -138,10 +138,10 @@ export function AdvancedChart({
|
||||
|
||||
// Indicator configuration
|
||||
const [indicators, setIndicators] = useState<IndicatorConfig[]>([
|
||||
{ id: 'volume', name: 'Volume', enabled: true, color: '#3B82F6' },
|
||||
{ id: 'volume', name: 'Volume', enabled: true, color: '#E0483B' },
|
||||
{ id: 'ma5', name: 'MA5', enabled: false, color: '#FF6B6B', params: { period: 5 } },
|
||||
{ id: 'ma10', name: 'MA10', enabled: false, color: '#4ECDC4', params: { period: 10 } },
|
||||
{ id: 'ma20', name: 'MA20', enabled: false, color: '#FFD93D', params: { period: 20 } },
|
||||
{ id: 'ma20', name: 'MA20', enabled: false, color: '#E0483B', params: { period: 20 } },
|
||||
{ id: 'ma60', name: 'MA60', enabled: false, color: '#95E1D3', params: { period: 60 } },
|
||||
{ id: 'ema12', name: 'EMA12', enabled: false, color: '#A8E6CF', params: { period: 12 } },
|
||||
{ id: 'ema26', name: 'EMA26', enabled: false, color: '#FFD3B6', params: { period: 26 } },
|
||||
@@ -356,18 +356,18 @@ export function AdvancedChart({
|
||||
width: chartContainerRef.current.clientWidth || 800,
|
||||
height: chartContainerRef.current.clientHeight || height,
|
||||
layout: {
|
||||
background: { color: '#0B0E11' },
|
||||
textColor: '#B7BDC6',
|
||||
background: { color: '#F1ECE2' },
|
||||
textColor: '#1A1813',
|
||||
fontSize: 12,
|
||||
},
|
||||
grid: {
|
||||
vertLines: {
|
||||
color: 'rgba(43, 49, 57, 0.2)',
|
||||
color: 'rgba(26, 24, 19, 0.08)',
|
||||
style: 1,
|
||||
visible: true,
|
||||
},
|
||||
horzLines: {
|
||||
color: 'rgba(43, 49, 57, 0.2)',
|
||||
color: 'rgba(26, 24, 19, 0.08)',
|
||||
style: 1,
|
||||
visible: true,
|
||||
},
|
||||
@@ -375,20 +375,20 @@ export function AdvancedChart({
|
||||
crosshair: {
|
||||
mode: 1,
|
||||
vertLine: {
|
||||
color: 'rgba(240, 185, 11, 0.5)',
|
||||
color: 'rgba(224, 72, 59, 0.5)',
|
||||
width: 1,
|
||||
style: 2,
|
||||
labelBackgroundColor: '#F0B90B',
|
||||
labelBackgroundColor: '#E0483B',
|
||||
},
|
||||
horzLine: {
|
||||
color: 'rgba(240, 185, 11, 0.5)',
|
||||
color: 'rgba(224, 72, 59, 0.5)',
|
||||
width: 1,
|
||||
style: 2,
|
||||
labelBackgroundColor: '#F0B90B',
|
||||
labelBackgroundColor: '#E0483B',
|
||||
},
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: '#2B3139',
|
||||
borderColor: 'rgba(26, 24, 19, 0.14)',
|
||||
scaleMargins: {
|
||||
top: 0.1,
|
||||
bottom: 0.25,
|
||||
@@ -397,7 +397,7 @@ export function AdvancedChart({
|
||||
entireTextOnly: false,
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: '#2B3139',
|
||||
borderColor: 'rgba(26, 24, 19, 0.14)',
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
borderVisible: true,
|
||||
@@ -433,18 +433,18 @@ export function AdvancedChart({
|
||||
|
||||
// Create candlestick series
|
||||
const candlestickSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: '#0ECB81',
|
||||
downColor: '#F6465D',
|
||||
borderUpColor: '#0ECB81',
|
||||
borderDownColor: '#F6465D',
|
||||
wickUpColor: '#0ECB81',
|
||||
wickDownColor: '#F6465D',
|
||||
upColor: '#2E8B57',
|
||||
downColor: '#D6433A',
|
||||
borderUpColor: '#2E8B57',
|
||||
borderDownColor: '#D6433A',
|
||||
wickUpColor: '#2E8B57',
|
||||
wickDownColor: '#D6433A',
|
||||
})
|
||||
candlestickSeriesRef.current = candlestickSeries as any
|
||||
|
||||
// Create volume series
|
||||
const volumeSeries = chart.addSeries(HistogramSeries, {
|
||||
color: '#26a69a',
|
||||
color: '#2E8B57',
|
||||
priceFormat: {
|
||||
type: 'volume',
|
||||
},
|
||||
@@ -579,7 +579,7 @@ export function AdvancedChart({
|
||||
const volumeData = klineData.map((k: Kline) => ({
|
||||
time: k.time,
|
||||
value: k.volume || 0,
|
||||
color: k.close >= k.open ? 'rgba(14, 203, 129, 0.5)' : 'rgba(246, 70, 93, 0.5)',
|
||||
color: k.close >= k.open ? 'rgba(46, 139, 87, 0.5)' : 'rgba(214, 67, 58, 0.5)',
|
||||
}))
|
||||
volumeSeriesRef.current.setData(volumeData)
|
||||
} else {
|
||||
@@ -666,7 +666,7 @@ export function AdvancedChart({
|
||||
markers.push({
|
||||
time: candleTime as Time,
|
||||
position: 'belowBar' as const,
|
||||
color: '#0ECB81',
|
||||
color: '#2E8B57',
|
||||
shape: 'circle' as const,
|
||||
text: counts.buys > 1 ? `B${counts.buys}` : 'B',
|
||||
size: 1,
|
||||
@@ -677,7 +677,7 @@ export function AdvancedChart({
|
||||
markers.push({
|
||||
time: candleTime as Time,
|
||||
position: 'aboveBar' as const,
|
||||
color: '#F6465D',
|
||||
color: '#D6433A',
|
||||
shape: 'circle' as const,
|
||||
text: counts.sells > 1 ? `S${counts.sells}` : 'S',
|
||||
size: 1,
|
||||
@@ -780,18 +780,18 @@ export function AdvancedChart({
|
||||
const isLimit = order.type === 'LIMIT'
|
||||
|
||||
// Set price line style
|
||||
let lineColor = '#F0B90B' // Default yellow
|
||||
let lineColor = '#E0483B' // Default vermilion
|
||||
const lineStyle = 2 // dashed
|
||||
let title = ''
|
||||
|
||||
if (isStopLoss) {
|
||||
lineColor = '#F6465D' // red - stop loss
|
||||
lineColor = '#D6433A' // red - stop loss
|
||||
title = `SL ${order.quantity}`
|
||||
} else if (isTakeProfit) {
|
||||
lineColor = '#0ECB81' // green - take profit
|
||||
lineColor = '#2E8B57' // green - take profit
|
||||
title = `TP ${order.quantity}`
|
||||
} else if (isLimit) {
|
||||
lineColor = '#F0B90B' // yellow - limit order
|
||||
lineColor = '#E0483B' // vermilion - limit order
|
||||
title = `Limit ${order.side} ${order.quantity}`
|
||||
} else {
|
||||
title = `${order.type} ${order.quantity}`
|
||||
@@ -918,10 +918,10 @@ export function AdvancedChart({
|
||||
<div
|
||||
className="relative shadow-xl"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, #0F1215 0%, #0B0E11 100%)',
|
||||
background: '#F1ECE2',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(43, 49, 57, 0.5)',
|
||||
border: '1px solid rgba(26, 24, 19, 0.14)',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -930,19 +930,19 @@ export function AdvancedChart({
|
||||
{/* Compact Professional Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2"
|
||||
style={{ borderBottom: '1px solid rgba(43, 49, 57, 0.6)', background: '#0D1117', flexShrink: 0 }}
|
||||
style={{ borderBottom: '1px solid rgba(26, 24, 19, 0.14)', background: '#F7F4EC', flexShrink: 0 }}
|
||||
>
|
||||
{/* Left: Symbol Info + Price */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Symbol & Interval */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-white">{symbol}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[#1F2937] text-gray-400">{interval}</span>
|
||||
<span className="text-sm font-bold text-nofx-text">{symbol}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-nofx-bg-deeper text-nofx-text-muted">{interval}</span>
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded font-medium uppercase"
|
||||
style={{
|
||||
background: exchange === 'hyperliquid' ? 'rgba(80, 227, 194, 0.1)' : 'rgba(243, 186, 47, 0.1)',
|
||||
color: exchange === 'hyperliquid' ? '#50E3C2' : '#F3BA2F',
|
||||
background: 'rgba(224, 72, 59, 0.1)',
|
||||
color: '#E0483B',
|
||||
}}
|
||||
>
|
||||
{exchange?.toUpperCase()}
|
||||
@@ -951,10 +951,10 @@ export function AdvancedChart({
|
||||
|
||||
{/* Price Display */}
|
||||
{marketStats && (
|
||||
<div className="flex items-center gap-3 pl-3 border-l border-[#2B3139]">
|
||||
<div className="flex items-center gap-3 pl-3 border-l border-[rgba(26,24,19,0.14)]">
|
||||
<span
|
||||
className="text-base font-bold tabular-nums"
|
||||
style={{ color: marketStats.priceChange >= 0 ? '#10B981' : '#EF4444' }}
|
||||
style={{ color: marketStats.priceChange >= 0 ? '#2E8B57' : '#D6433A' }}
|
||||
>
|
||||
{marketStats.price.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
@@ -964,19 +964,19 @@ export function AdvancedChart({
|
||||
<span
|
||||
className="text-xs font-medium px-1.5 py-0.5 rounded tabular-nums"
|
||||
style={{
|
||||
background: marketStats.priceChange >= 0 ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)',
|
||||
color: marketStats.priceChange >= 0 ? '#10B981' : '#EF4444',
|
||||
background: marketStats.priceChange >= 0 ? 'rgba(46, 139, 87, 0.1)' : 'rgba(214, 67, 58, 0.1)',
|
||||
color: marketStats.priceChange >= 0 ? '#2E8B57' : '#D6433A',
|
||||
}}
|
||||
>
|
||||
{marketStats.priceChange >= 0 ? '+' : ''}{marketStats.priceChangePercent.toFixed(2)}%
|
||||
</span>
|
||||
|
||||
{/* Compact H/L */}
|
||||
<div className="flex items-center gap-2 text-[11px] text-gray-500">
|
||||
<span>H <span className="text-gray-300">{marketStats.high.toFixed(2)}</span></span>
|
||||
<span>L <span className="text-gray-300">{marketStats.low.toFixed(2)}</span></span>
|
||||
<div className="flex items-center gap-2 text-[11px] text-nofx-text-muted">
|
||||
<span>H <span className="text-nofx-text">{marketStats.high.toFixed(2)}</span></span>
|
||||
<span>L <span className="text-nofx-text">{marketStats.low.toFixed(2)}</span></span>
|
||||
{marketStats.volume > 0 && baseUnit && (
|
||||
<span>Vol <span className="text-gray-300">{formatVolume(marketStats.volume)}</span></span>
|
||||
<span>Vol <span className="text-nofx-text">{formatVolume(marketStats.volume)}</span></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -986,7 +986,7 @@ export function AdvancedChart({
|
||||
{/* Right: Controls */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{loading && (
|
||||
<span className="text-[10px] text-yellow-400 animate-pulse mr-2">
|
||||
<span className="text-[10px] text-nofx-gold animate-pulse mr-2">
|
||||
{t('advancedChart.updating', language)}
|
||||
</span>
|
||||
)}
|
||||
@@ -994,8 +994,8 @@ export function AdvancedChart({
|
||||
onClick={() => setShowIndicatorPanel(!showIndicatorPanel)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
background: showIndicatorPanel ? 'rgba(96, 165, 250, 0.15)' : 'transparent',
|
||||
color: showIndicatorPanel ? '#60A5FA' : '#6B7280',
|
||||
background: showIndicatorPanel ? 'rgba(224, 72, 59, 0.12)' : 'transparent',
|
||||
color: showIndicatorPanel ? '#E0483B' : '#8A8478',
|
||||
}}
|
||||
>
|
||||
<Settings className="w-3 h-3" />
|
||||
@@ -1006,8 +1006,8 @@ export function AdvancedChart({
|
||||
onClick={() => setShowOrderMarkers(!showOrderMarkers)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
background: showOrderMarkers ? 'rgba(16, 185, 129, 0.15)' : 'transparent',
|
||||
color: showOrderMarkers ? '#10B981' : '#6B7280',
|
||||
background: showOrderMarkers ? 'rgba(46, 139, 87, 0.15)' : 'transparent',
|
||||
color: showOrderMarkers ? '#2E8B57' : '#8A8478',
|
||||
}}
|
||||
title={t('advancedChart.orderMarkers', language)}
|
||||
>
|
||||
@@ -1021,8 +1021,8 @@ export function AdvancedChart({
|
||||
<div
|
||||
className="absolute top-16 right-4 z-10 rounded-lg shadow-2xl backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1A1E23 0%, #0F1215 100%)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
background: '#F7F4EC',
|
||||
border: '1px solid rgba(224, 72, 59, 0.2)',
|
||||
maxHeight: '500px',
|
||||
minWidth: '280px',
|
||||
overflowY: 'auto',
|
||||
@@ -1031,17 +1031,17 @@ export function AdvancedChart({
|
||||
{/* Title bar */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 border-b"
|
||||
style={{ borderColor: 'rgba(43, 49, 57, 0.5)' }}
|
||||
style={{ borderColor: 'rgba(26, 24, 19, 0.14)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart2 className="w-4 h-4 text-yellow-400" />
|
||||
<h4 className="text-sm font-bold text-white">
|
||||
<BarChart2 className="w-4 h-4 text-nofx-gold" />
|
||||
<h4 className="text-sm font-bold text-nofx-text">
|
||||
{t('advancedChart.technicalIndicators', language)}
|
||||
</h4>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowIndicatorPanel(false)}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
className="text-nofx-text-muted hover:text-nofx-text transition-colors"
|
||||
>
|
||||
<span className="text-lg">×</span>
|
||||
</button>
|
||||
@@ -1052,25 +1052,25 @@ export function AdvancedChart({
|
||||
{indicators.map(indicator => (
|
||||
<label
|
||||
key={indicator.id}
|
||||
className="flex items-center gap-3 p-2.5 rounded-md hover:bg-white/5 cursor-pointer transition-all group"
|
||||
className="flex items-center gap-3 p-2.5 rounded-md hover:bg-black/5 cursor-pointer transition-all group"
|
||||
>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={indicator.enabled}
|
||||
onChange={() => toggleIndicator(indicator.id)}
|
||||
className="w-4 h-4 rounded border-gray-600 text-yellow-500 focus:ring-2 focus:ring-yellow-500/50"
|
||||
className="w-4 h-4 rounded border-[rgba(26,24,19,0.3)] text-nofx-gold focus:ring-2 focus:ring-nofx-gold/50"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="w-8 h-3 rounded-sm border border-white/10"
|
||||
className="w-8 h-3 rounded-sm border border-[rgba(26,24,19,0.14)]"
|
||||
style={{ backgroundColor: indicator.color }}
|
||||
></div>
|
||||
<span className="text-sm text-gray-300 group-hover:text-white transition-colors flex-1">
|
||||
<span className="text-sm text-nofx-text-muted group-hover:text-nofx-text transition-colors flex-1">
|
||||
{indicator.name}
|
||||
</span>
|
||||
{indicator.enabled && (
|
||||
<span className="text-xs text-yellow-400">●</span>
|
||||
<span className="text-xs text-nofx-gold">●</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
@@ -1078,8 +1078,8 @@ export function AdvancedChart({
|
||||
|
||||
{/* Bottom hint */}
|
||||
<div
|
||||
className="px-4 py-2 text-xs text-gray-500 border-t"
|
||||
style={{ borderColor: 'rgba(43, 49, 57, 0.5)' }}
|
||||
className="px-4 py-2 text-xs text-nofx-text-muted border-t"
|
||||
style={{ borderColor: 'rgba(26, 24, 19, 0.14)' }}
|
||||
>
|
||||
{t('advancedChart.clickToToggle', language)}
|
||||
</div>
|
||||
@@ -1099,19 +1099,19 @@ export function AdvancedChart({
|
||||
left: '10px',
|
||||
top: '10px',
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(15, 18, 21, 0.95)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.3)',
|
||||
background: 'rgba(247, 244, 236, 0.95)',
|
||||
border: '1px solid rgba(224, 72, 59, 0.3)',
|
||||
borderRadius: '6px',
|
||||
color: '#EAECEF',
|
||||
color: '#1A1813',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
backdropFilter: 'blur(10px)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.5)',
|
||||
boxShadow: '0 4px 12px rgba(26, 24, 19, 0.15)',
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '6px', color: '#F0B90B', fontWeight: 'bold', fontSize: '11px' }}>
|
||||
<div style={{ marginBottom: '6px', color: '#E0483B', fontWeight: 'bold', fontSize: '11px' }}>
|
||||
{new Date((tooltipData.time as number) * 1000).toLocaleString(language === 'zh' ? 'zh-CN' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -1120,18 +1120,18 @@ export function AdvancedChart({
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '4px 12px', fontSize: '11px' }}>
|
||||
<span style={{ color: '#848E9C' }}>O:</span>
|
||||
<span style={{ color: '#EAECEF', fontWeight: '500' }}>{tooltipData.open?.toFixed(2)}</span>
|
||||
<span style={{ color: '#8A8478' }}>O:</span>
|
||||
<span style={{ color: '#1A1813', fontWeight: '500' }}>{tooltipData.open?.toFixed(2)}</span>
|
||||
|
||||
<span style={{ color: '#848E9C' }}>H:</span>
|
||||
<span style={{ color: '#0ECB81', fontWeight: '500' }}>{tooltipData.high?.toFixed(2)}</span>
|
||||
<span style={{ color: '#8A8478' }}>H:</span>
|
||||
<span style={{ color: '#2E8B57', fontWeight: '500' }}>{tooltipData.high?.toFixed(2)}</span>
|
||||
|
||||
<span style={{ color: '#848E9C' }}>L:</span>
|
||||
<span style={{ color: '#F6465D', fontWeight: '500' }}>{tooltipData.low?.toFixed(2)}</span>
|
||||
<span style={{ color: '#8A8478' }}>L:</span>
|
||||
<span style={{ color: '#D6433A', fontWeight: '500' }}>{tooltipData.low?.toFixed(2)}</span>
|
||||
|
||||
<span style={{ color: '#848E9C' }}>C:</span>
|
||||
<span style={{ color: '#8A8478' }}>C:</span>
|
||||
<span style={{
|
||||
color: tooltipData.close >= tooltipData.open ? '#0ECB81' : '#F6465D',
|
||||
color: tooltipData.close >= tooltipData.open ? '#2E8B57' : '#D6433A',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{tooltipData.close?.toFixed(2)}
|
||||
@@ -1139,8 +1139,8 @@ export function AdvancedChart({
|
||||
|
||||
{tooltipData.volume > 0 && baseUnit && (
|
||||
<>
|
||||
<span style={{ color: '#848E9C' }}>V({baseUnit}):</span>
|
||||
<span style={{ color: '#3B82F6', fontWeight: '500' }}>
|
||||
<span style={{ color: '#8A8478' }}>V({baseUnit}):</span>
|
||||
<span style={{ color: '#E0483B', fontWeight: '500' }}>
|
||||
{formatVolume(tooltipData.volume)}
|
||||
</span>
|
||||
</>
|
||||
@@ -1148,8 +1148,8 @@ export function AdvancedChart({
|
||||
|
||||
{tooltipData.quoteVolume > 0 && quoteUnit && (
|
||||
<>
|
||||
<span style={{ color: '#848E9C' }}>V({quoteUnit}):</span>
|
||||
<span style={{ color: '#3B82F6', fontWeight: '500' }}>
|
||||
<span style={{ color: '#8A8478' }}>V({quoteUnit}):</span>
|
||||
<span style={{ color: '#E0483B', fontWeight: '500' }}>
|
||||
{formatVolume(tooltipData.quoteVolume)}
|
||||
</span>
|
||||
</>
|
||||
@@ -1173,10 +1173,9 @@ export function AdvancedChart({
|
||||
style={{
|
||||
fontSize: '56px',
|
||||
fontWeight: '700',
|
||||
color: 'rgba(240, 185, 11, 0.12)',
|
||||
color: 'rgba(224, 72, 59, 0.12)',
|
||||
letterSpacing: '4px',
|
||||
fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
|
||||
textShadow: '0 2px 30px rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
>
|
||||
NOFX
|
||||
@@ -1188,11 +1187,11 @@ export function AdvancedChart({
|
||||
{error && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{ background: 'rgba(11, 14, 17, 0.9)' }}
|
||||
style={{ background: 'rgba(241, 236, 226, 0.9)' }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">⚠️</div>
|
||||
<div style={{ color: '#F6465D' }}>{error}</div>
|
||||
<div style={{ color: '#D6433A' }}>{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -148,7 +148,7 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`nofx-glass rounded-lg border border-white/5 relative z-10 w-full flex flex-col transition-all duration-300 ${typeof window !== 'undefined' && window.innerWidth < 768 ? 'h-[500px]' : 'h-[600px]'
|
||||
<div className={`nofx-glass rounded-lg border border-[rgba(26,24,19,0.1)] relative z-10 w-full flex flex-col transition-all duration-300 ${typeof window !== 'undefined' && window.innerWidth < 768 ? 'h-[500px]' : 'h-[600px]'
|
||||
}`}>
|
||||
{/*
|
||||
Premium Professional Toolbar
|
||||
@@ -156,16 +156,16 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
Desktop: Standard flex-wrap/nowrap
|
||||
*/}
|
||||
<div
|
||||
className="relative z-20 flex flex-wrap md:flex-nowrap items-center justify-between gap-y-2 px-3 py-2 shrink-0 backdrop-blur-md bg-[#0B0E11]/80 rounded-t-lg"
|
||||
style={{ borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}
|
||||
className="relative z-20 flex flex-wrap md:flex-nowrap items-center justify-between gap-y-2 px-3 py-2 shrink-0 backdrop-blur-md bg-nofx-bg/80 rounded-t-lg"
|
||||
style={{ borderBottom: '1px solid rgba(26, 24, 19, 0.14)' }}
|
||||
>
|
||||
{/* Left: Tab Switcher */}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('equity')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[11px] font-medium transition-all ${activeTab === 'equity'
|
||||
? 'bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20 shadow-[0_0_10px_rgba(240,185,11,0.1)]'
|
||||
: 'text-nofx-text-muted hover:text-nofx-text-main hover:bg-white/5'
|
||||
? 'bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20'
|
||||
: 'text-nofx-text-muted hover:text-nofx-text-main hover:bg-black/5'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="w-3.5 h-3.5" />
|
||||
@@ -176,8 +176,8 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
<button
|
||||
onClick={() => setActiveTab('kline')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-[11px] font-medium transition-all ${activeTab === 'kline'
|
||||
? 'bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20 shadow-[0_0_10px_rgba(240,185,11,0.1)]'
|
||||
: 'text-nofx-text-muted hover:text-nofx-text-main hover:bg-white/5'
|
||||
? 'bg-nofx-gold/10 text-nofx-gold border border-nofx-gold/20'
|
||||
: 'text-nofx-text-muted hover:text-nofx-text-main hover:bg-black/5'
|
||||
}`}
|
||||
>
|
||||
<CandlestickChart className="w-3.5 h-3.5" />
|
||||
@@ -187,7 +187,7 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
|
||||
{/* Market Type Pills - Only when kline active, HIDDEN on mobile to save space */}
|
||||
{activeTab === 'kline' && (
|
||||
<div className="hidden md:flex items-center gap-1 ml-2 border-l border-white/10 pl-2">
|
||||
<div className="hidden md:flex items-center gap-1 ml-2 border-l border-[rgba(26,24,19,0.14)] pl-2">
|
||||
{(Object.keys(MARKET_CONFIG) as MarketType[]).map((type) => {
|
||||
const config = MARKET_CONFIG[type]
|
||||
const isActive = marketType === type
|
||||
@@ -196,8 +196,8 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
key={type}
|
||||
onClick={() => handleMarketTypeChange(type)}
|
||||
className={`px-2.5 py-1 text-[10px] font-medium rounded transition-all border ${isActive
|
||||
? 'bg-white/10 text-white border-white/20'
|
||||
: 'text-nofx-text-muted border-transparent hover:text-nofx-text-main hover:bg-white/5'
|
||||
? 'bg-nofx-gold/10 text-nofx-gold border-nofx-gold/20'
|
||||
: 'text-nofx-text-muted border-transparent hover:text-nofx-text-main hover:bg-black/5'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1 opacity-70">{config.icon}</span>
|
||||
@@ -218,22 +218,22 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-black/40 border border-white/10 rounded text-[11px] font-bold text-nofx-text-main hover:border-nofx-gold/30 hover:text-nofx-gold transition-all"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-nofx-bg-deeper border border-[rgba(26,24,19,0.14)] rounded text-[11px] font-bold text-nofx-text-main hover:border-nofx-gold/30 hover:text-nofx-gold transition-all"
|
||||
>
|
||||
<span>{chartSymbolDisplay}</span>
|
||||
<ChevronDown className={`w-3 h-3 text-nofx-text-muted transition-transform ${showDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{showDropdown && (
|
||||
<div className="absolute top-full right-0 mt-2 w-64 bg-[#0B0E11] border border-white/10 rounded-lg shadow-[0_10px_40px_-10px_rgba(0,0,0,0.5)] z-50 overflow-hidden nofx-glass ring-1 ring-white/5">
|
||||
<div className="p-2 border-b border-white/5">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 bg-black/40 rounded border border-white/10 focus-within:border-nofx-gold/50 transition-colors">
|
||||
<div className="absolute top-full right-0 mt-2 w-64 bg-nofx-bg-lighter border border-[rgba(26,24,19,0.14)] rounded-lg shadow-[0_10px_40px_-10px_rgba(26,24,19,0.2)] z-50 overflow-hidden nofx-glass ring-1 ring-[rgba(26,24,19,0.08)]">
|
||||
<div className="p-2 border-b border-[rgba(26,24,19,0.1)]">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 bg-nofx-bg-deeper rounded border border-[rgba(26,24,19,0.14)] focus-within:border-nofx-gold/50 transition-colors">
|
||||
<Search className="w-3.5 h-3.5 text-nofx-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
placeholder="Search symbol..."
|
||||
className="flex-1 bg-transparent text-[11px] text-white placeholder-gray-600 focus:outline-none font-mono"
|
||||
className="flex-1 bg-transparent text-[11px] text-nofx-text placeholder-nofx-text-muted focus:outline-none font-mono"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -245,12 +245,12 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
const labels: Record<string, string> = { crypto: 'Crypto', stock: 'Stocks', forex: 'Forex', commodity: 'Commodities', index: 'Indices', pre_ipo: 'Pre-IPO' }
|
||||
return (
|
||||
<div key={category}>
|
||||
<div className="px-3 py-1.5 text-[9px] font-bold text-nofx-text-muted/60 bg-white/5 uppercase tracking-wider">{labels[category]}</div>
|
||||
<div className="px-3 py-1.5 text-[9px] font-bold text-nofx-text-muted/60 bg-black/5 uppercase tracking-wider">{labels[category]}</div>
|
||||
{categorySymbols.map(s => (
|
||||
<button
|
||||
key={s.symbol}
|
||||
onClick={() => { setChartSymbol(s.symbol); setShowDropdown(false); setSearchFilter('') }}
|
||||
className={`w-full px-3 py-2 text-left text-[11px] font-mono hover:bg-white/5 transition-all flex items-center justify-between ${chartSymbol === s.symbol ? 'bg-nofx-gold/10 text-nofx-gold' : 'text-nofx-text-muted'}`}
|
||||
className={`w-full px-3 py-2 text-left text-[11px] font-mono hover:bg-black/5 transition-all flex items-center justify-between ${chartSymbol === s.symbol ? 'bg-nofx-gold/10 text-nofx-gold' : 'text-nofx-text-muted'}`}
|
||||
>
|
||||
<span>{s.display || s.symbol}</span>
|
||||
<span className="text-[9px] opacity-40">{s.name}</span>
|
||||
@@ -264,19 +264,19 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="px-2.5 py-1 bg-black/40 border border-white/10 rounded text-[11px] font-bold text-nofx-text-main font-mono">{chartSymbol}</span>
|
||||
<span className="px-2.5 py-1 bg-nofx-bg-deeper border border-[rgba(26,24,19,0.14)] rounded text-[11px] font-bold text-nofx-text-main font-mono">{chartSymbol}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interval Selector - Allow scrolling if needed */}
|
||||
<div className="flex items-center bg-black/40 rounded border border-white/10 overflow-x-auto no-scrollbar max-w-[200px] md:max-w-none">
|
||||
<div className="flex items-center bg-nofx-bg-deeper rounded border border-[rgba(26,24,19,0.14)] overflow-x-auto no-scrollbar max-w-[200px] md:max-w-none">
|
||||
{INTERVALS.map((int) => (
|
||||
<button
|
||||
key={int.value}
|
||||
onClick={() => setInterval(int.value)}
|
||||
className={`px-2 py-1 text-[10px] font-medium transition-all ${interval === int.value
|
||||
? 'bg-nofx-gold/20 text-nofx-gold'
|
||||
: 'text-nofx-text-muted hover:text-white hover:bg-white/5'
|
||||
: 'text-nofx-text-muted hover:text-nofx-text hover:bg-black/5'
|
||||
}`}
|
||||
>
|
||||
{int.label}
|
||||
@@ -291,9 +291,9 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
value={symbolInput}
|
||||
onChange={(e) => setSymbolInput(e.target.value)}
|
||||
placeholder="Sym"
|
||||
className="w-16 px-2 py-1 bg-black/40 border border-white/10 rounded-l text-[10px] text-white placeholder-gray-600 focus:outline-none focus:border-nofx-gold/50 font-mono transition-colors"
|
||||
className="w-16 px-2 py-1 bg-nofx-bg-deeper border border-[rgba(26,24,19,0.14)] rounded-l text-[10px] text-nofx-text placeholder-nofx-text-muted focus:outline-none focus:border-nofx-gold/50 font-mono transition-colors"
|
||||
/>
|
||||
<button type="submit" className="px-2 py-1 bg-white/5 border border-white/10 border-l-0 rounded-r text-[10px] text-nofx-text-muted hover:text-white hover:bg-white/10 transition-all">
|
||||
<button type="submit" className="px-2 py-1 bg-black/5 border border-[rgba(26,24,19,0.14)] border-l-0 rounded-r text-[10px] text-nofx-text-muted hover:text-nofx-text hover:bg-black/10 transition-all">
|
||||
Go
|
||||
</button>
|
||||
</form>
|
||||
@@ -302,7 +302,7 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
||||
</div>
|
||||
|
||||
{/* Tab Content - Chart autosizes to this container */}
|
||||
<div className="relative flex-1 bg-[#0B0E11]/50 rounded-b-lg overflow-hidden h-full min-h-0">
|
||||
<div className="relative flex-1 bg-nofx-bg/50 rounded-b-lg overflow-hidden h-full min-h-0">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === 'equity' ? (
|
||||
<motion.div
|
||||
|
||||
@@ -210,21 +210,21 @@ export function ChartWithOrders({
|
||||
width: chartContainerRef.current.clientWidth,
|
||||
height: height,
|
||||
layout: {
|
||||
background: { color: '#0B0E11' },
|
||||
textColor: '#EAECEF',
|
||||
background: { color: '#F1ECE2' },
|
||||
textColor: '#1A1813',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: 'rgba(43, 49, 57, 0.5)' },
|
||||
horzLines: { color: 'rgba(43, 49, 57, 0.5)' },
|
||||
vertLines: { color: 'rgba(26, 24, 19, 0.08)' },
|
||||
horzLines: { color: 'rgba(26, 24, 19, 0.08)' },
|
||||
},
|
||||
crosshair: {
|
||||
mode: 1, // Normal crosshair
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: '#2B3139',
|
||||
borderColor: 'rgba(26, 24, 19, 0.14)',
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: '#2B3139',
|
||||
borderColor: 'rgba(26, 24, 19, 0.14)',
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
},
|
||||
@@ -246,12 +246,12 @@ export function ChartWithOrders({
|
||||
|
||||
// Create candlestick series (using v5 API)
|
||||
const candlestickSeries = chart.addSeries(CandlestickSeries, {
|
||||
upColor: '#0ECB81',
|
||||
downColor: '#F6465D',
|
||||
borderUpColor: '#0ECB81',
|
||||
borderDownColor: '#F6465D',
|
||||
wickUpColor: '#0ECB81',
|
||||
wickDownColor: '#F6465D',
|
||||
upColor: '#2E8B57',
|
||||
downColor: '#D6433A',
|
||||
borderUpColor: '#2E8B57',
|
||||
borderDownColor: '#D6433A',
|
||||
wickUpColor: '#2E8B57',
|
||||
wickDownColor: '#D6433A',
|
||||
})
|
||||
|
||||
candlestickSeriesRef.current = candlestickSeries as any
|
||||
@@ -380,7 +380,7 @@ export function ChartWithOrders({
|
||||
markers.push({
|
||||
time: alignedTime as Time,
|
||||
position: 'belowBar' as const,
|
||||
color: isBuy ? '#0ECB81' : '#F6465D',
|
||||
color: isBuy ? '#2E8B57' : '#D6433A',
|
||||
shape: 'circle' as const,
|
||||
text: isBuy ? 'B' : 'S',
|
||||
price: order.price,
|
||||
@@ -431,17 +431,17 @@ export function ChartWithOrders({
|
||||
}, [symbol, interval, traderID, language])
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ background: '#0B0E11', borderRadius: '8px', overflow: 'hidden' }}>
|
||||
<div className="relative" style={{ background: '#F1ECE2', borderRadius: '8px', overflow: 'hidden' }}>
|
||||
{/* Title bar */}
|
||||
<div className="flex items-center justify-between p-4" style={{ borderBottom: '1px solid #2B3139' }}>
|
||||
<div className="flex items-center justify-between p-4" style={{ borderBottom: '1px solid rgba(26, 24, 19, 0.14)' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">📈</span>
|
||||
<h3 className="text-lg font-bold" style={{ color: '#EAECEF' }}>
|
||||
<h3 className="text-lg font-bold" style={{ color: '#1A1813' }}>
|
||||
{symbol} {interval}
|
||||
</h3>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="text-sm" style={{ color: '#848E9C' }}>
|
||||
<div className="text-sm" style={{ color: '#8A8478' }}>
|
||||
{t('chartWithOrders.loading', language)}
|
||||
</div>
|
||||
)}
|
||||
@@ -460,19 +460,19 @@ export function ChartWithOrders({
|
||||
left: '10px',
|
||||
top: '10px',
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(15, 18, 21, 0.95)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.3)',
|
||||
background: 'rgba(247, 244, 236, 0.95)',
|
||||
border: '1px solid rgba(224, 72, 59, 0.3)',
|
||||
borderRadius: '6px',
|
||||
color: '#EAECEF',
|
||||
color: '#1A1813',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
backdropFilter: 'blur(10px)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.5)',
|
||||
boxShadow: '0 4px 12px rgba(26, 24, 19, 0.15)',
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '6px', color: '#F0B90B', fontWeight: 'bold', fontSize: '11px' }}>
|
||||
<div style={{ marginBottom: '6px', color: '#E0483B', fontWeight: 'bold', fontSize: '11px' }}>
|
||||
{new Date((tooltipData.time as number) * 1000).toLocaleString(language === 'zh' ? 'zh-CN' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@@ -481,18 +481,18 @@ export function ChartWithOrders({
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '4px 12px', fontSize: '11px' }}>
|
||||
<span style={{ color: '#848E9C' }}>O:</span>
|
||||
<span style={{ color: '#EAECEF', fontWeight: '500' }}>{tooltipData.open?.toFixed(2)}</span>
|
||||
<span style={{ color: '#8A8478' }}>O:</span>
|
||||
<span style={{ color: '#1A1813', fontWeight: '500' }}>{tooltipData.open?.toFixed(2)}</span>
|
||||
|
||||
<span style={{ color: '#848E9C' }}>H:</span>
|
||||
<span style={{ color: '#0ECB81', fontWeight: '500' }}>{tooltipData.high?.toFixed(2)}</span>
|
||||
<span style={{ color: '#8A8478' }}>H:</span>
|
||||
<span style={{ color: '#2E8B57', fontWeight: '500' }}>{tooltipData.high?.toFixed(2)}</span>
|
||||
|
||||
<span style={{ color: '#848E9C' }}>L:</span>
|
||||
<span style={{ color: '#F6465D', fontWeight: '500' }}>{tooltipData.low?.toFixed(2)}</span>
|
||||
<span style={{ color: '#8A8478' }}>L:</span>
|
||||
<span style={{ color: '#D6433A', fontWeight: '500' }}>{tooltipData.low?.toFixed(2)}</span>
|
||||
|
||||
<span style={{ color: '#848E9C' }}>C:</span>
|
||||
<span style={{ color: '#8A8478' }}>C:</span>
|
||||
<span style={{
|
||||
color: tooltipData.close >= tooltipData.open ? '#0ECB81' : '#F6465D',
|
||||
color: tooltipData.close >= tooltipData.open ? '#2E8B57' : '#D6433A',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{tooltipData.close?.toFixed(2)}
|
||||
@@ -506,23 +506,23 @@ export function ChartWithOrders({
|
||||
{error && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{ background: 'rgba(11, 14, 17, 0.9)' }}
|
||||
style={{ background: 'rgba(241, 236, 226, 0.9)' }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">⚠️</div>
|
||||
<div style={{ color: '#F6465D' }}>{error}</div>
|
||||
<div style={{ color: '#D6433A' }}>{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 p-4 text-xs" style={{ borderTop: '1px solid #2B3139', color: '#848E9C' }}>
|
||||
<div className="flex items-center gap-4 p-4 text-xs" style={{ borderTop: '1px solid rgba(26, 24, 19, 0.14)', color: '#8A8478' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold" style={{ color: '#0ECB81' }}>B</span>
|
||||
<span className="font-bold" style={{ color: '#2E8B57' }}>B</span>
|
||||
<span>{t('chartWithOrders.buy', language)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold" style={{ color: '#F6465D' }}>S</span>
|
||||
<span className="font-bold" style={{ color: '#D6433A' }}>S</span>
|
||||
<span>{t('chartWithOrders.sell', language)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ export function ChartWithOrdersSimple({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// 从我们自己的服务获取K线数据
|
||||
// Fetch kline data from our own service
|
||||
const limit = 100
|
||||
const klineUrl = `/api/klines?symbol=${symbol}&interval=${interval}&limit=${limit}`
|
||||
|
||||
@@ -40,7 +40,7 @@ export function ChartWithOrdersSimple({
|
||||
console.log('[ChartSimple] Received klines:', klineResult.data.length)
|
||||
setKlineCount(klineResult.data.length)
|
||||
|
||||
// 测试获取订单数据
|
||||
// Test fetching order data
|
||||
if (traderID) {
|
||||
const tradesUrl = `/api/trades?trader_id=${traderID}&symbol=${symbol}&limit=100`
|
||||
console.log('[ChartSimple] Fetching trades from:', tradesUrl)
|
||||
@@ -66,51 +66,51 @@ export function ChartWithOrdersSimple({
|
||||
}, [symbol, interval, traderID])
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ background: '#0B0E11', borderRadius: '8px', overflow: 'hidden', minHeight: height }}>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between p-4" style={{ borderBottom: '1px solid #2B3139' }}>
|
||||
<div className="relative" style={{ background: '#F1ECE2', borderRadius: '8px', overflow: 'hidden', minHeight: height }}>
|
||||
{/* Title bar */}
|
||||
<div className="flex items-center justify-between p-4" style={{ borderBottom: '1px solid rgba(26, 24, 19, 0.14)' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">📈</span>
|
||||
<h3 className="text-lg font-bold" style={{ color: '#EAECEF' }}>
|
||||
{symbol} {interval} (测试模式)
|
||||
<h3 className="text-lg font-bold" style={{ color: '#1A1813' }}>
|
||||
{symbol} {interval} (Test Mode)
|
||||
</h3>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="text-sm" style={{ color: '#848E9C' }}>
|
||||
加载中...
|
||||
<div className="text-sm" style={{ color: '#8A8478' }}>
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 测试信息 */}
|
||||
{/* Test info */}
|
||||
<div className="p-8 space-y-4">
|
||||
{error ? (
|
||||
<div className="text-center">
|
||||
<div className="text-2xl mb-2">⚠️</div>
|
||||
<div style={{ color: '#F6465D' }}>{error}</div>
|
||||
<div style={{ color: '#D6433A' }}>{error}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-4 rounded" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||
<div className="text-sm mb-2" style={{ color: '#848E9C' }}>币安K线数据</div>
|
||||
<div className="text-2xl font-bold" style={{ color: '#0ECB81' }}>
|
||||
{klineCount} 根K线
|
||||
<div className="p-4 rounded" style={{ background: '#F7F4EC', border: '1px solid rgba(26, 24, 19, 0.14)' }}>
|
||||
<div className="text-sm mb-2" style={{ color: '#8A8478' }}>Binance Kline Data</div>
|
||||
<div className="text-2xl font-bold" style={{ color: '#2E8B57' }}>
|
||||
{klineCount} klines
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{traderID && (
|
||||
<div className="p-4 rounded" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||
<div className="text-sm mb-2" style={{ color: '#848E9C' }}>历史订单数据</div>
|
||||
<div className="text-2xl font-bold" style={{ color: '#F0B90B' }}>
|
||||
{orderCount} 笔订单
|
||||
<div className="p-4 rounded" style={{ background: '#F7F4EC', border: '1px solid rgba(26, 24, 19, 0.14)' }}>
|
||||
<div className="text-sm mb-2" style={{ color: '#8A8478' }}>Historical Order Data</div>
|
||||
<div className="text-2xl font-bold" style={{ color: '#E0483B' }}>
|
||||
{orderCount} orders
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 rounded" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||
<div className="text-sm mb-2" style={{ color: '#848E9C' }}>状态</div>
|
||||
<div className="text-lg" style={{ color: '#EAECEF' }}>
|
||||
✅ 数据获取正常,图表组件开发中
|
||||
<div className="p-4 rounded" style={{ background: '#F7F4EC', border: '1px solid rgba(26, 24, 19, 0.14)' }}>
|
||||
<div className="text-sm mb-2" style={{ color: '#8A8478' }}>Status</div>
|
||||
<div className="text-lg" style={{ color: '#1A1813' }}>
|
||||
✅ Data fetched successfully, chart component in development
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -191,11 +191,11 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 border-4 border-t-transparent rounded-full animate-spin"
|
||||
style={{ borderColor: '#F0B90B', borderTopColor: 'transparent' }} />
|
||||
style={{ borderColor: '#E0483B', borderTopColor: 'transparent' }} />
|
||||
<TrendingUp className="w-6 h-6 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ color: '#F0B90B' }} />
|
||||
style={{ color: '#E0483B' }} />
|
||||
</div>
|
||||
<div className="text-sm mt-4 font-medium" style={{ color: '#848E9C' }}>
|
||||
<div className="text-sm mt-4 font-medium" style={{ color: '#8A8478' }}>
|
||||
{t('loadingChartData', language) || 'Loading chart data...'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,13 +206,13 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<div className="w-20 h-20 rounded-2xl flex items-center justify-center mb-4"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.1)' }}>
|
||||
<BarChart3 className="w-10 h-10" style={{ color: '#F0B90B', opacity: 0.6 }} />
|
||||
style={{ background: 'rgba(224, 72, 59, 0.1)' }}>
|
||||
<BarChart3 className="w-10 h-10" style={{ color: '#E0483B', opacity: 0.6 }} />
|
||||
</div>
|
||||
<div className="text-lg font-bold mb-2" style={{ color: '#EAECEF' }}>
|
||||
<div className="text-lg font-bold mb-2" style={{ color: '#1A1813' }}>
|
||||
{t('noHistoricalData', language)}
|
||||
</div>
|
||||
<div className="text-sm text-center max-w-xs" style={{ color: '#848E9C' }}>
|
||||
<div className="text-sm text-center max-w-xs" style={{ color: '#8A8478' }}>
|
||||
{t('dataWillAppear', language)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -264,14 +264,14 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
<div
|
||||
className="rounded-xl p-4 shadow-2xl backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'rgba(30, 35, 41, 0.95)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
background: 'rgba(247, 244, 236, 0.95)',
|
||||
border: '1px solid rgba(224, 72, 59, 0.2)',
|
||||
minWidth: '200px'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3 pb-2" style={{ borderBottom: '1px solid #2B3139' }}>
|
||||
<Zap className="w-3.5 h-3.5" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#F0B90B' }}>
|
||||
<div className="flex items-center gap-2 mb-3 pb-2" style={{ borderBottom: '1px solid rgba(26, 24, 19, 0.14)' }}>
|
||||
<Zap className="w-3.5 h-3.5" style={{ color: '#E0483B' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#E0483B' }}>
|
||||
{dateStr} {data.time}
|
||||
</span>
|
||||
</div>
|
||||
@@ -288,17 +288,17 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
<div className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ background: traderColor(trader.trader_id) }} />
|
||||
<span className="text-xs font-medium truncate max-w-[100px]"
|
||||
style={{ color: '#EAECEF' }}>
|
||||
style={{ color: '#1A1813' }}>
|
||||
{trader.trader_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-bold mono flex items-center gap-1"
|
||||
style={{ color: isPositive ? '#0ECB81' : '#F6465D' }}>
|
||||
style={{ color: isPositive ? '#2E8B57' : '#D6433A' }}>
|
||||
{isPositive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
|
||||
{isPositive ? '+' : ''}{pnlPct.toFixed(2)}%
|
||||
</div>
|
||||
<div className="text-[10px] mono" style={{ color: '#5E6673' }}>
|
||||
<div className="text-[10px] mono" style={{ color: '#8A8478' }}>
|
||||
${equity?.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -346,10 +346,10 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-all"
|
||||
style={{
|
||||
background: selectedPeriod === period.key
|
||||
? 'rgba(240, 185, 11, 0.2)'
|
||||
: 'rgba(43, 49, 57, 0.5)',
|
||||
color: selectedPeriod === period.key ? '#F0B90B' : '#848E9C',
|
||||
border: `1px solid ${selectedPeriod === period.key ? 'rgba(240, 185, 11, 0.4)' : '#2B3139'}`,
|
||||
? 'rgba(224, 72, 59, 0.15)'
|
||||
: 'rgba(26, 24, 19, 0.04)',
|
||||
color: selectedPeriod === period.key ? '#E0483B' : '#8A8478',
|
||||
border: `1px solid ${selectedPeriod === period.key ? 'rgba(224, 72, 59, 0.4)' : 'rgba(26, 24, 19, 0.14)'}`,
|
||||
}}
|
||||
>
|
||||
{t(`comparisonChart.${period.key}`, language)}
|
||||
@@ -363,17 +363,17 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
<div key={trader.trader_id}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-full transition-all hover:scale-105"
|
||||
style={{
|
||||
background: idx === 0 ? 'rgba(240, 185, 11, 0.15)' : 'rgba(43, 49, 57, 0.5)',
|
||||
border: `1px solid ${idx === 0 ? 'rgba(240, 185, 11, 0.3)' : '#2B3139'}`
|
||||
background: idx === 0 ? 'rgba(224, 72, 59, 0.15)' : 'rgba(26, 24, 19, 0.04)',
|
||||
border: `1px solid ${idx === 0 ? 'rgba(224, 72, 59, 0.3)' : 'rgba(26, 24, 19, 0.14)'}`
|
||||
}}>
|
||||
<div className="w-2 h-2 rounded-full"
|
||||
style={{ background: traderColor(trader.trader_id) }} />
|
||||
<span className="text-xs font-medium truncate max-w-[80px]"
|
||||
style={{ color: '#EAECEF' }}>
|
||||
style={{ color: '#1A1813' }}>
|
||||
{trader.trader_name}
|
||||
</span>
|
||||
<span className="text-xs font-bold mono"
|
||||
style={{ color: trader.currentPnl >= 0 ? '#0ECB81' : '#F6465D' }}>
|
||||
style={{ color: trader.currentPnl >= 0 ? '#2E8B57' : '#D6433A' }}>
|
||||
{trader.currentPnl >= 0 ? '+' : ''}{trader.currentPnl.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -383,7 +383,7 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
|
||||
{/* Chart */}
|
||||
<div className="relative rounded-xl overflow-hidden"
|
||||
style={{ background: 'linear-gradient(180deg, rgba(11, 14, 17, 0.8) 0%, rgba(11, 14, 17, 1) 100%)' }}>
|
||||
style={{ background: '#F1ECE2' }}>
|
||||
{/* Watermark */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
@@ -392,7 +392,7 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '80px',
|
||||
fontWeight: 'bold',
|
||||
color: 'rgba(240, 185, 11, 0.03)',
|
||||
color: 'rgba(224, 72, 59, 0.04)',
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
fontFamily: 'monospace',
|
||||
@@ -427,20 +427,20 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1E2329" vertical={false} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(26, 24, 19, 0.10)" vertical={false} />
|
||||
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="#2B3139"
|
||||
tick={{ fill: '#5E6673', fontSize: 10 }}
|
||||
stroke="#6B6557"
|
||||
tick={{ fill: '#6B6557', fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#2B3139' }}
|
||||
axisLine={{ stroke: 'rgba(26, 24, 19, 0.14)' }}
|
||||
interval={Math.max(Math.floor(displayData.length / 8), 1)}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
stroke="#2B3139"
|
||||
tick={{ fill: '#5E6673', fontSize: 10 }}
|
||||
stroke="#6B6557"
|
||||
tick={{ fill: '#6B6557', fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
domain={calculateYDomain()}
|
||||
@@ -453,7 +453,7 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
{/* Zero reference line */}
|
||||
<ReferenceLine
|
||||
y={0}
|
||||
stroke="#474D57"
|
||||
stroke="rgba(26, 24, 19, 0.2)"
|
||||
strokeDasharray="8 4"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
@@ -482,13 +482,11 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
activeDot={{
|
||||
r: 6,
|
||||
fill: traderColor(trader.trader_id),
|
||||
stroke: '#0B0E11',
|
||||
stroke: '#F1ECE2',
|
||||
strokeWidth: 2,
|
||||
filter: 'url(#glow)',
|
||||
}}
|
||||
name={trader.trader_name}
|
||||
connectNulls
|
||||
style={{ filter: idx === 0 ? 'url(#glow)' : undefined }}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -515,10 +513,10 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
borderRadius: '50%',
|
||||
backgroundColor: entry.color
|
||||
}} />
|
||||
<span style={{ color: '#EAECEF', fontSize: '12px', fontWeight: 500 }}>
|
||||
<span style={{ color: '#1A1813', fontSize: '12px', fontWeight: 500 }}>
|
||||
{entry.value}
|
||||
<span style={{
|
||||
color: pnl >= 0 ? '#0ECB81' : '#F6465D',
|
||||
color: pnl >= 0 ? '#2E8B57' : '#D6433A',
|
||||
marginLeft: '6px',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
@@ -539,36 +537,36 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
|
||||
{/* Bottom Stats */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="p-3 rounded-lg text-center"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.05)', border: '1px solid rgba(240, 185, 11, 0.1)' }}>
|
||||
<div className="text-[10px] uppercase tracking-wider mb-1" style={{ color: '#848E9C' }}>
|
||||
style={{ background: 'rgba(224, 72, 59, 0.05)', border: '1px solid rgba(224, 72, 59, 0.1)' }}>
|
||||
<div className="text-[10px] uppercase tracking-wider mb-1" style={{ color: '#8A8478' }}>
|
||||
{t('leader', language)}
|
||||
</div>
|
||||
<div className="text-sm font-bold truncate" style={{ color: '#F0B90B' }}>
|
||||
<div className="text-sm font-bold truncate" style={{ color: '#E0483B' }}>
|
||||
{leader?.trader_name || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg text-center" style={{ background: 'rgba(14, 203, 129, 0.05)' }}>
|
||||
<div className="text-[10px] uppercase tracking-wider mb-1" style={{ color: '#848E9C' }}>
|
||||
<div className="p-3 rounded-lg text-center" style={{ background: 'rgba(46, 139, 87, 0.05)' }}>
|
||||
<div className="text-[10px] uppercase tracking-wider mb-1" style={{ color: '#8A8478' }}>
|
||||
{t('leadPnL', language) || 'Lead PnL'}
|
||||
</div>
|
||||
<div className="text-sm font-bold mono"
|
||||
style={{ color: (leader?.currentPnl || 0) >= 0 ? '#0ECB81' : '#F6465D' }}>
|
||||
style={{ color: (leader?.currentPnl || 0) >= 0 ? '#2E8B57' : '#D6433A' }}>
|
||||
{(leader?.currentPnl || 0) >= 0 ? '+' : ''}{(leader?.currentPnl || 0).toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg text-center" style={{ background: 'rgba(96, 165, 250, 0.05)' }}>
|
||||
<div className="text-[10px] uppercase tracking-wider mb-1" style={{ color: '#848E9C' }}>
|
||||
<div className="p-3 rounded-lg text-center" style={{ background: 'rgba(26, 24, 19, 0.04)' }}>
|
||||
<div className="text-[10px] uppercase tracking-wider mb-1" style={{ color: '#8A8478' }}>
|
||||
{t('currentGap', language)}
|
||||
</div>
|
||||
<div className="text-sm font-bold mono" style={{ color: '#60a5fa' }}>
|
||||
<div className="text-sm font-bold mono" style={{ color: '#1A1813' }}>
|
||||
{gap}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg text-center" style={{ background: 'rgba(139, 92, 246, 0.05)' }}>
|
||||
<div className="text-[10px] uppercase tracking-wider mb-1" style={{ color: '#848E9C' }}>
|
||||
<div className="p-3 rounded-lg text-center" style={{ background: 'rgba(26, 24, 19, 0.04)' }}>
|
||||
<div className="text-[10px] uppercase tracking-wider mb-1" style={{ color: '#8A8478' }}>
|
||||
{t('dataPoints', language)}
|
||||
</div>
|
||||
<div className="text-sm font-bold mono" style={{ color: '#8b5cf6' }}>
|
||||
<div className="text-sm font-bold mono" style={{ color: '#1A1813' }}>
|
||||
{displayData.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@ interface EquityPoint {
|
||||
|
||||
interface EquityChartProps {
|
||||
traderId?: string
|
||||
embedded?: boolean // 嵌入模式(不显示外层卡片)
|
||||
embedded?: boolean // Embedded mode (does not show the outer card)
|
||||
}
|
||||
|
||||
export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
@@ -45,7 +45,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
user && token && traderId ? `equity-history-${traderId}` : null,
|
||||
() => api.getEquityHistory(traderId, true),
|
||||
{
|
||||
refreshInterval: 30000, // 30秒刷新(历史数据更新频率较低)
|
||||
refreshInterval: 30000, // Refresh every 30s (historical data updates less frequently)
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 20000,
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
user && token && traderId ? `account-${traderId}` : null,
|
||||
() => api.getAccount(traderId, true),
|
||||
{
|
||||
refreshInterval: 15000, // 15秒刷新(配合后端缓存)
|
||||
refreshInterval: 15000, // Refresh every 15s (matches backend cache)
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 10000,
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
return (
|
||||
<div className={embedded ? 'p-6' : 'binance-card p-6'}>
|
||||
{!embedded && (
|
||||
<h3 className="text-lg font-semibold mb-6" style={{ color: '#EAECEF' }}>
|
||||
<h3 className="text-lg font-semibold mb-6" style={{ color: '#1A1813' }}>
|
||||
{t('accountEquityCurve', language)}
|
||||
</h3>
|
||||
)}
|
||||
@@ -83,16 +83,16 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
<div
|
||||
className="flex items-center gap-3 p-4 rounded"
|
||||
style={{
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
border: '1px solid rgba(246, 70, 93, 0.2)',
|
||||
background: 'rgba(214, 67, 58, 0.1)',
|
||||
border: '1px solid rgba(214, 67, 58, 0.2)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="w-6 h-6" style={{ color: '#F6465D' }} />
|
||||
<AlertTriangle className="w-6 h-6" style={{ color: '#D6433A' }} />
|
||||
<div>
|
||||
<div className="font-semibold" style={{ color: '#F6465D' }}>
|
||||
<div className="font-semibold" style={{ color: '#D6433A' }}>
|
||||
{t('loadingError', language)}
|
||||
</div>
|
||||
<div className="text-sm" style={{ color: '#848E9C' }}>
|
||||
<div className="text-sm" style={{ color: '#8A8478' }}>
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,18 +101,18 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// 过滤掉无效数据:total_equity为0或小于1的数据点(API失败导致)
|
||||
// Filter out invalid data: points where total_equity is 0 or less than 1 (caused by API failures)
|
||||
const validHistory = history?.filter((point) => point.total_equity > 1) || []
|
||||
|
||||
if (!validHistory || validHistory.length === 0) {
|
||||
return (
|
||||
<div className={embedded ? 'p-6' : 'binance-card p-6'}>
|
||||
{!embedded && (
|
||||
<h3 className="text-lg font-semibold mb-6" style={{ color: '#EAECEF' }}>
|
||||
<h3 className="text-lg font-semibold mb-6" style={{ color: '#1A1813' }}>
|
||||
{t('accountEquityCurve', language)}
|
||||
</h3>
|
||||
)}
|
||||
<div className="text-center py-16" style={{ color: '#848E9C' }}>
|
||||
<div className="text-center py-16" style={{ color: '#8A8478' }}>
|
||||
<div className="mb-4 flex justify-center opacity-50">
|
||||
<BarChart3 className="w-16 h-16" />
|
||||
</div>
|
||||
@@ -125,23 +125,23 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// 限制显示最近的数据点(性能优化)
|
||||
// 如果数据超过2000个点,只显示最近2000个
|
||||
// Limit to the most recent data points (performance optimization)
|
||||
// If there are more than 2000 points, only show the most recent 2000
|
||||
const MAX_DISPLAY_POINTS = 2000
|
||||
const displayHistory =
|
||||
validHistory.length > MAX_DISPLAY_POINTS
|
||||
? validHistory.slice(-MAX_DISPLAY_POINTS)
|
||||
: validHistory
|
||||
|
||||
// 计算初始余额(优先从 account 获取配置的初始余额,备选从历史数据反推)
|
||||
// Compute the initial balance (prefer the configured value from account, fall back to deriving from history)
|
||||
const initialBalance =
|
||||
account?.initial_balance || // 从交易员配置读取真实初始余额
|
||||
account?.initial_balance || // Read the real initial balance from the trader config
|
||||
(validHistory[0]
|
||||
? validHistory[0].total_equity - validHistory[0].pnl
|
||||
: undefined) || // 备选:淨值 - 盈亏
|
||||
1000 // 默认值(与创建交易员时的默认配置一致)
|
||||
: undefined) || // Fallback: equity - pnl
|
||||
1000 // Default value (matches the default config used when creating a trader)
|
||||
|
||||
// 转换数据格式
|
||||
// Transform the data format
|
||||
const chartData = displayHistory.map((point, index) => {
|
||||
const pnl = point.total_equity - initialBalance
|
||||
const pnlPct = ((pnl / initialBalance) * 100).toFixed(2)
|
||||
@@ -161,45 +161,45 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
const currentValue = chartData[chartData.length - 1]
|
||||
const isProfit = currentValue.raw_pnl >= 0
|
||||
|
||||
// 计算Y轴范围
|
||||
// Compute the Y-axis range
|
||||
const calculateYDomain = () => {
|
||||
if (displayMode === 'percent') {
|
||||
// 百分比模式:找到最大最小值,留20%余量
|
||||
// Percent mode: find the min/max values, leave a 20% margin
|
||||
const values = chartData.map((d) => d.value)
|
||||
const minVal = Math.min(...values)
|
||||
const maxVal = Math.max(...values)
|
||||
const range = Math.max(Math.abs(maxVal), Math.abs(minVal))
|
||||
const padding = Math.max(range * 0.2, 1) // 至少留1%余量
|
||||
const padding = Math.max(range * 0.2, 1) // Leave at least a 1% margin
|
||||
return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)]
|
||||
} else {
|
||||
// 美元模式:以初始余额为基准,上下留10%余量
|
||||
// Dollar mode: anchor on the initial balance, leave a 10% margin above and below
|
||||
const values = chartData.map((d) => d.value)
|
||||
const minVal = Math.min(...values, initialBalance)
|
||||
const maxVal = Math.max(...values, initialBalance)
|
||||
const range = maxVal - minVal
|
||||
const padding = Math.max(range * 0.15, initialBalance * 0.01) // 至少留1%余量
|
||||
const padding = Math.max(range * 0.15, initialBalance * 0.01) // Leave at least a 1% margin
|
||||
return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)]
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义Tooltip - Binance Style
|
||||
// Custom Tooltip - Binance Style
|
||||
const CustomTooltip = ({ active, payload }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload
|
||||
return (
|
||||
<div
|
||||
className="rounded p-3 shadow-xl"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
|
||||
style={{ background: '#F7F4EC', border: '1px solid rgba(26, 24, 19, 0.14)' }}
|
||||
>
|
||||
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>
|
||||
<div className="text-xs mb-1" style={{ color: '#8A8478' }}>
|
||||
Cycle #{data.cycle != null ? data.cycle : '—'}
|
||||
</div>
|
||||
<div className="font-bold mono" style={{ color: '#EAECEF' }}>
|
||||
<div className="font-bold mono" style={{ color: '#1A1813' }}>
|
||||
{data.raw_equity.toFixed(2)} USDT
|
||||
</div>
|
||||
<div
|
||||
className="text-sm mono font-bold"
|
||||
style={{ color: data.raw_pnl >= 0 ? '#0ECB81' : '#F6465D' }}
|
||||
style={{ color: data.raw_pnl >= 0 ? '#2E8B57' : '#D6433A' }}
|
||||
>
|
||||
{data.raw_pnl >= 0 ? '+' : ''}
|
||||
{data.raw_pnl.toFixed(2)} USDT ({data.raw_pnl_pct >= 0 ? '+' : ''}
|
||||
@@ -219,7 +219,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
{!embedded && (
|
||||
<h3
|
||||
className="text-base sm:text-lg font-bold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
style={{ color: '#1A1813' }}
|
||||
>
|
||||
{t('accountEquityCurve', language)}
|
||||
</h3>
|
||||
@@ -227,12 +227,12 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
<div className="flex flex-col sm:flex-row sm:items-baseline gap-2 sm:gap-4">
|
||||
<span
|
||||
className="text-2xl sm:text-3xl font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
style={{ color: '#1A1813' }}
|
||||
>
|
||||
{account?.total_equity.toFixed(2) || '0.00'}
|
||||
<span
|
||||
className="text-base sm:text-lg ml-1"
|
||||
style={{ color: '#848E9C' }}
|
||||
style={{ color: '#8A8478' }}
|
||||
>
|
||||
USDT
|
||||
</span>
|
||||
@@ -241,14 +241,14 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
<span
|
||||
className="text-sm sm:text-lg font-bold mono px-2 sm:px-3 py-1 rounded flex items-center gap-1"
|
||||
style={{
|
||||
color: isProfit ? '#0ECB81' : '#F6465D',
|
||||
color: isProfit ? '#2E8B57' : '#D6433A',
|
||||
background: isProfit
|
||||
? 'rgba(14, 203, 129, 0.1)'
|
||||
: 'rgba(246, 70, 93, 0.1)',
|
||||
? 'rgba(46, 139, 87, 0.1)'
|
||||
: 'rgba(214, 67, 58, 0.1)',
|
||||
border: `1px solid ${
|
||||
isProfit
|
||||
? 'rgba(14, 203, 129, 0.2)'
|
||||
: 'rgba(246, 70, 93, 0.2)'
|
||||
? 'rgba(46, 139, 87, 0.2)'
|
||||
: 'rgba(214, 67, 58, 0.2)'
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
@@ -262,7 +262,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
</span>
|
||||
<span
|
||||
className="text-xs sm:text-sm mono"
|
||||
style={{ color: '#848E9C' }}
|
||||
style={{ color: '#8A8478' }}
|
||||
>
|
||||
({isProfit ? '+' : ''}
|
||||
{currentValue.raw_pnl.toFixed(2)} USDT)
|
||||
@@ -274,7 +274,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
{/* Display Mode Toggle */}
|
||||
<div
|
||||
className="flex gap-0.5 sm:gap-1 rounded p-0.5 sm:p-1 self-start sm:self-auto"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
style={{ background: '#E8E2D5', border: '1px solid rgba(26, 24, 19, 0.14)' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setDisplayMode('dollar')}
|
||||
@@ -282,11 +282,10 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
style={
|
||||
displayMode === 'dollar'
|
||||
? {
|
||||
background: '#F0B90B',
|
||||
color: '#000',
|
||||
boxShadow: '0 2px 8px rgba(240, 185, 11, 0.4)',
|
||||
background: '#E0483B',
|
||||
color: '#F1ECE2',
|
||||
}
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
: { background: 'transparent', color: '#8A8478' }
|
||||
}
|
||||
>
|
||||
<DollarSign className="w-4 h-4" /> USDT
|
||||
@@ -297,11 +296,10 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
style={
|
||||
displayMode === 'percent'
|
||||
? {
|
||||
background: '#F0B90B',
|
||||
color: '#000',
|
||||
boxShadow: '0 2px 8px rgba(240, 185, 11, 0.4)',
|
||||
background: '#E0483B',
|
||||
color: '#F1ECE2',
|
||||
}
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
: { background: 'transparent', color: '#8A8478' }
|
||||
}
|
||||
>
|
||||
<Percent className="w-4 h-4" />
|
||||
@@ -326,7 +324,7 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
right: '15px',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: 'rgba(240, 185, 11, 0.15)',
|
||||
color: 'rgba(224, 72, 59, 0.15)',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
fontFamily: 'monospace',
|
||||
@@ -341,25 +339,25 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#F0B90B" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#FCD535" stopOpacity={0.2} />
|
||||
<stop offset="5%" stopColor="#E0483B" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#E0483B" stopOpacity={0.2} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(26, 24, 19, 0.10)" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="#5E6673"
|
||||
tick={{ fill: '#848E9C', fontSize: 11 }}
|
||||
tickLine={{ stroke: '#2B3139' }}
|
||||
stroke="#6B6557"
|
||||
tick={{ fill: '#6B6557', fontSize: 11 }}
|
||||
tickLine={{ stroke: 'rgba(26, 24, 19, 0.14)' }}
|
||||
interval={Math.floor(chartData.length / 10)}
|
||||
angle={-15}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#5E6673"
|
||||
tick={{ fill: '#848E9C', fontSize: 12 }}
|
||||
tickLine={{ stroke: '#2B3139' }}
|
||||
stroke="#6B6557"
|
||||
tick={{ fill: '#6B6557', fontSize: 12 }}
|
||||
tickLine={{ stroke: 'rgba(26, 24, 19, 0.14)' }}
|
||||
domain={calculateYDomain()}
|
||||
tickFormatter={(value) =>
|
||||
displayMode === 'dollar' ? `$${value.toFixed(0)}` : `${value}%`
|
||||
@@ -368,14 +366,14 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<ReferenceLine
|
||||
y={displayMode === 'dollar' ? initialBalance : 0}
|
||||
stroke="#474D57"
|
||||
stroke="rgba(26, 24, 19, 0.2)"
|
||||
strokeDasharray="3 3"
|
||||
label={{
|
||||
value:
|
||||
displayMode === 'dollar'
|
||||
? t('initialBalance', language).split(' ')[0]
|
||||
: '0%',
|
||||
fill: '#848E9C',
|
||||
fill: '#8A8478',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
@@ -384,11 +382,11 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
dataKey="value"
|
||||
stroke="url(#colorGradient)"
|
||||
strokeWidth={3}
|
||||
dot={chartData.length > 50 ? false : { fill: '#F0B90B', r: 3 }}
|
||||
dot={chartData.length > 50 ? false : { fill: '#E0483B', r: 3 }}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
fill: '#FCD535',
|
||||
stroke: '#F0B90B',
|
||||
fill: '#E0483B',
|
||||
stroke: '#F1ECE2',
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
connectNulls={true}
|
||||
@@ -400,72 +398,72 @@ export function EquityChart({ traderId, embedded = false }: EquityChartProps) {
|
||||
{/* Footer Stats */}
|
||||
<div
|
||||
className="mt-3 grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 pt-3"
|
||||
style={{ borderTop: '1px solid #2B3139' }}
|
||||
style={{ borderTop: '1px solid rgba(26, 24, 19, 0.14)' }}
|
||||
>
|
||||
<div
|
||||
className="p-2 rounded transition-all hover:bg-opacity-50"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
|
||||
style={{ background: 'rgba(224, 72, 59, 0.05)' }}
|
||||
>
|
||||
<div
|
||||
className="text-xs mb-1 uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
style={{ color: '#8A8478' }}
|
||||
>
|
||||
{t('initialBalance', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs sm:text-sm font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
style={{ color: '#1A1813' }}
|
||||
>
|
||||
{initialBalance.toFixed(2)} USDT
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="p-2 rounded transition-all hover:bg-opacity-50"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
|
||||
style={{ background: 'rgba(224, 72, 59, 0.05)' }}
|
||||
>
|
||||
<div
|
||||
className="text-xs mb-1 uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
style={{ color: '#8A8478' }}
|
||||
>
|
||||
{t('currentEquity', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs sm:text-sm font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
style={{ color: '#1A1813' }}
|
||||
>
|
||||
{currentValue.raw_equity.toFixed(2)} USDT
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="p-2 rounded transition-all hover:bg-opacity-50"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
|
||||
style={{ background: 'rgba(224, 72, 59, 0.05)' }}
|
||||
>
|
||||
<div
|
||||
className="text-xs mb-1 uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
style={{ color: '#8A8478' }}
|
||||
>
|
||||
{t('historicalCycles', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs sm:text-sm font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
style={{ color: '#1A1813' }}
|
||||
>
|
||||
{validHistory.length} {t('cycles', language)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="p-2 rounded transition-all hover:bg-opacity-50"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.05)' }}
|
||||
style={{ background: 'rgba(224, 72, 59, 0.05)' }}
|
||||
>
|
||||
<div
|
||||
className="text-xs mb-1 uppercase tracking-wider"
|
||||
style={{ color: '#848E9C' }}
|
||||
style={{ color: '#8A8478' }}
|
||||
>
|
||||
{t('displayRange', language)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs sm:text-sm font-bold mono"
|
||||
style={{ color: '#EAECEF' }}
|
||||
style={{ color: '#1A1813' }}
|
||||
>
|
||||
{validHistory.length > MAX_DISPLAY_POINTS
|
||||
? `${t('recent', language)} ${MAX_DISPLAY_POINTS}`
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { ChevronDown, TrendingUp, X } from 'lucide-react'
|
||||
|
||||
// 支持的交易所列表 (合约格式)
|
||||
// Supported exchanges list (futures format)
|
||||
const EXCHANGES = [
|
||||
{ id: 'BINANCE', name: 'Binance', prefix: 'BINANCE:', suffix: '.P' },
|
||||
{ id: 'BYBIT', name: 'Bybit', prefix: 'BYBIT:', suffix: '.P' },
|
||||
@@ -13,7 +13,7 @@ const EXCHANGES = [
|
||||
{ id: 'GATEIO', name: 'Gate.io', prefix: 'GATEIO:', suffix: '.P' },
|
||||
] as const
|
||||
|
||||
// 热门交易对
|
||||
// Popular trading pairs
|
||||
const POPULAR_SYMBOLS = [
|
||||
'BTCUSDT',
|
||||
'ETHUSDT',
|
||||
@@ -29,7 +29,7 @@ const POPULAR_SYMBOLS = [
|
||||
'LTCUSDT',
|
||||
]
|
||||
|
||||
// 时间周期选项
|
||||
// Time interval options
|
||||
const INTERVALS = [
|
||||
{ id: '1', label: '1m' },
|
||||
{ id: '5', label: '5m' },
|
||||
@@ -46,7 +46,7 @@ interface TradingViewChartProps {
|
||||
defaultExchange?: string
|
||||
height?: number
|
||||
showToolbar?: boolean
|
||||
embedded?: boolean // 嵌入模式(不显示外层卡片)
|
||||
embedded?: boolean // Embedded mode (does not show the outer card)
|
||||
}
|
||||
|
||||
function TradingViewChartComponent({
|
||||
@@ -66,26 +66,26 @@ function TradingViewChartComponent({
|
||||
const [showSymbolDropdown, setShowSymbolDropdown] = useState(false)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
// 当外部传入的 defaultSymbol 变化时,更新内部 symbol
|
||||
// Update the internal symbol when the external defaultSymbol changes
|
||||
useEffect(() => {
|
||||
if (defaultSymbol && defaultSymbol !== symbol) {
|
||||
// console.log('[TradingViewChart] 更新币种:', defaultSymbol)
|
||||
// console.log('[TradingViewChart] Updating symbol:', defaultSymbol)
|
||||
setSymbol(defaultSymbol)
|
||||
}
|
||||
}, [defaultSymbol])
|
||||
|
||||
// 当外部传入的 defaultExchange 变化时,更新内部 exchange
|
||||
// Update the internal exchange when the external defaultExchange changes
|
||||
useEffect(() => {
|
||||
if (defaultExchange && defaultExchange !== exchange) {
|
||||
const normalizedExchange = defaultExchange.toUpperCase()
|
||||
// console.log('[TradingViewChart] 更新交易所:', normalizedExchange)
|
||||
// console.log('[TradingViewChart] Updating exchange:', normalizedExchange)
|
||||
if (EXCHANGES.some(e => e.id === normalizedExchange)) {
|
||||
setExchange(normalizedExchange)
|
||||
}
|
||||
}
|
||||
}, [defaultExchange])
|
||||
|
||||
// 获取完整的交易对符号 (合约格式: BINANCE:BTCUSDT.P)
|
||||
// Get the full trading pair symbol (futures format: BINANCE:BTCUSDT.P)
|
||||
const getFullSymbol = () => {
|
||||
const exchangeInfo = EXCHANGES.find((e) => e.id === exchange)
|
||||
const prefix = exchangeInfo?.prefix || 'BINANCE:'
|
||||
@@ -93,14 +93,14 @@ function TradingViewChartComponent({
|
||||
return `${prefix}${symbol}${suffix}`
|
||||
}
|
||||
|
||||
// 加载 TradingView Widget
|
||||
// Load the TradingView Widget
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
// 清空容器
|
||||
// Clear the container
|
||||
containerRef.current.innerHTML = ''
|
||||
|
||||
// 创建 widget 容器
|
||||
// Create the widget container
|
||||
const widgetContainer = document.createElement('div')
|
||||
widgetContainer.className = 'tradingview-widget-container'
|
||||
widgetContainer.style.height = '100%'
|
||||
@@ -114,7 +114,7 @@ function TradingViewChartComponent({
|
||||
widgetContainer.appendChild(widgetDiv)
|
||||
containerRef.current.appendChild(widgetContainer)
|
||||
|
||||
// 加载 TradingView 脚本
|
||||
// Load the TradingView script
|
||||
const script = document.createElement('script')
|
||||
script.src =
|
||||
'https://s3.tradingview.com/external-embedding/embed-widget-advanced-chart.js'
|
||||
@@ -126,12 +126,12 @@ function TradingViewChartComponent({
|
||||
symbol: getFullSymbol(),
|
||||
interval: timeInterval,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai',
|
||||
theme: 'dark',
|
||||
theme: 'light',
|
||||
style: '1',
|
||||
locale: language === 'zh' ? 'zh_CN' : 'en',
|
||||
enable_publishing: false,
|
||||
backgroundColor: 'rgba(11, 14, 17, 1)',
|
||||
gridColor: 'rgba(43, 49, 57, 0.5)',
|
||||
backgroundColor: 'rgba(241, 236, 226, 1)',
|
||||
gridColor: 'rgba(26, 24, 19, 0.08)',
|
||||
hide_top_toolbar: !showToolbar,
|
||||
hide_legend: false,
|
||||
save_image: false,
|
||||
@@ -149,11 +149,11 @@ function TradingViewChartComponent({
|
||||
}
|
||||
}, [exchange, symbol, timeInterval, language, showToolbar])
|
||||
|
||||
// 处理自定义交易对输入
|
||||
// Handle custom trading pair input
|
||||
const handleCustomSymbolSubmit = () => {
|
||||
if (customSymbol.trim()) {
|
||||
let sym = customSymbol.trim().toUpperCase()
|
||||
// 如果没有 USDT 后缀,自动加上
|
||||
// If there is no USDT suffix, add it automatically
|
||||
if (!sym.endsWith('USDT')) {
|
||||
sym = sym + 'USDT'
|
||||
}
|
||||
@@ -169,19 +169,19 @@ function TradingViewChartComponent({
|
||||
? 'fixed inset-0 z-50 rounded-none flex flex-col'
|
||||
: ''
|
||||
}`}
|
||||
style={isFullscreen ? { background: '#0B0E11' } : undefined}
|
||||
style={isFullscreen ? { background: '#F1ECE2' } : undefined}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2 p-3 sm:p-4"
|
||||
style={{ borderBottom: embedded ? 'none' : '1px solid #2B3139' }}
|
||||
style={{ borderBottom: embedded ? 'none' : '1px solid rgba(26, 24, 19, 0.14)' }}
|
||||
>
|
||||
{!embedded && (
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5" style={{ color: '#F0B90B' }} />
|
||||
<TrendingUp className="w-5 h-5" style={{ color: '#E0483B' }} />
|
||||
<h3
|
||||
className="text-base sm:text-lg font-bold"
|
||||
style={{ color: '#EAECEF' }}
|
||||
style={{ color: '#1A1813' }}
|
||||
>
|
||||
{t('marketChart', language)}
|
||||
</h3>
|
||||
@@ -199,21 +199,21 @@ function TradingViewChartComponent({
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded text-sm font-medium transition-all"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
background: '#F7F4EC',
|
||||
border: '1px solid rgba(26, 24, 19, 0.14)',
|
||||
color: '#1A1813',
|
||||
}}
|
||||
>
|
||||
{EXCHANGES.find((e) => e.id === exchange)?.name || exchange}
|
||||
<ChevronDown className="w-4 h-4" style={{ color: '#848E9C' }} />
|
||||
<ChevronDown className="w-4 h-4" style={{ color: '#8A8478' }} />
|
||||
</button>
|
||||
|
||||
{showExchangeDropdown && (
|
||||
<div
|
||||
className="absolute top-full left-0 mt-1 py-1 rounded-lg shadow-xl z-20 min-w-[120px]"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
background: '#F7F4EC',
|
||||
border: '1px solid rgba(26, 24, 19, 0.14)',
|
||||
}}
|
||||
>
|
||||
{EXCHANGES.map((ex) => (
|
||||
@@ -225,10 +225,10 @@ function TradingViewChartComponent({
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm transition-all hover:bg-opacity-50"
|
||||
style={{
|
||||
color: exchange === ex.id ? '#F0B90B' : '#EAECEF',
|
||||
color: exchange === ex.id ? '#E0483B' : '#1A1813',
|
||||
background:
|
||||
exchange === ex.id
|
||||
? 'rgba(240, 185, 11, 0.1)'
|
||||
? 'rgba(224, 72, 59, 0.1)'
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
@@ -248,9 +248,9 @@ function TradingViewChartComponent({
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded text-sm font-bold transition-all"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.3)',
|
||||
color: '#F0B90B',
|
||||
background: 'rgba(224, 72, 59, 0.1)',
|
||||
border: '1px solid rgba(224, 72, 59, 0.3)',
|
||||
color: '#E0483B',
|
||||
}}
|
||||
>
|
||||
{symbol}
|
||||
@@ -261,12 +261,12 @@ function TradingViewChartComponent({
|
||||
<div
|
||||
className="absolute top-full left-0 mt-1 py-2 rounded-lg shadow-xl z-20 w-[280px]"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
background: '#F7F4EC',
|
||||
border: '1px solid rgba(26, 24, 19, 0.14)',
|
||||
}}
|
||||
>
|
||||
{/* Custom Input */}
|
||||
<div className="px-3 pb-2" style={{ borderBottom: '1px solid #2B3139' }}>
|
||||
<div className="px-3 pb-2" style={{ borderBottom: '1px solid rgba(26, 24, 19, 0.14)' }}>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -276,17 +276,17 @@ function TradingViewChartComponent({
|
||||
placeholder={t('enterSymbol', language)}
|
||||
className="flex-1 px-3 py-1.5 rounded text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
background: '#F1ECE2',
|
||||
border: '1px solid rgba(26, 24, 19, 0.14)',
|
||||
color: '#1A1813',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCustomSymbolSubmit}
|
||||
className="px-3 py-1.5 rounded text-sm font-medium"
|
||||
style={{
|
||||
background: '#F0B90B',
|
||||
color: '#0B0E11',
|
||||
background: '#E0483B',
|
||||
color: '#F1ECE2',
|
||||
}}
|
||||
>
|
||||
OK
|
||||
@@ -298,7 +298,7 @@ function TradingViewChartComponent({
|
||||
<div className="px-2 pt-2">
|
||||
<div
|
||||
className="text-xs px-2 py-1 mb-1"
|
||||
style={{ color: '#848E9C' }}
|
||||
style={{ color: '#8A8478' }}
|
||||
>
|
||||
{t('popularSymbols', language)}
|
||||
</div>
|
||||
@@ -312,11 +312,11 @@ function TradingViewChartComponent({
|
||||
}}
|
||||
className="px-2 py-1.5 rounded text-xs font-medium transition-all"
|
||||
style={{
|
||||
color: symbol === sym ? '#F0B90B' : '#EAECEF',
|
||||
color: symbol === sym ? '#E0483B' : '#1A1813',
|
||||
background:
|
||||
symbol === sym
|
||||
? 'rgba(240, 185, 11, 0.1)'
|
||||
: 'rgba(43, 49, 57, 0.3)',
|
||||
? 'rgba(224, 72, 59, 0.1)'
|
||||
: 'rgba(26, 24, 19, 0.04)',
|
||||
}}
|
||||
>
|
||||
{sym.replace('USDT', '')}
|
||||
@@ -331,7 +331,7 @@ function TradingViewChartComponent({
|
||||
{/* Interval Selector */}
|
||||
<div
|
||||
className="flex gap-0.5 p-0.5 rounded"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
style={{ background: '#E8E2D5', border: '1px solid rgba(26, 24, 19, 0.14)' }}
|
||||
>
|
||||
{INTERVALS.map((int) => (
|
||||
<button
|
||||
@@ -339,8 +339,8 @@ function TradingViewChartComponent({
|
||||
onClick={() => setTimeInterval(int.id)}
|
||||
className="px-2 py-1 rounded text-xs font-medium transition-all"
|
||||
style={{
|
||||
background: timeInterval === int.id ? '#F0B90B' : 'transparent',
|
||||
color: timeInterval === int.id ? '#0B0E11' : '#848E9C',
|
||||
background: timeInterval === int.id ? '#E0483B' : 'transparent',
|
||||
color: timeInterval === int.id ? '#F1ECE2' : '#8A8478',
|
||||
}}
|
||||
>
|
||||
{int.label}
|
||||
@@ -353,9 +353,9 @@ function TradingViewChartComponent({
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-1.5 rounded transition-all"
|
||||
style={{
|
||||
background: isFullscreen ? '#F0B90B' : 'transparent',
|
||||
color: isFullscreen ? '#0B0E11' : '#848E9C',
|
||||
border: '1px solid #2B3139',
|
||||
background: isFullscreen ? '#E0483B' : 'transparent',
|
||||
color: isFullscreen ? '#F1ECE2' : '#8A8478',
|
||||
border: '1px solid rgba(26, 24, 19, 0.14)',
|
||||
}}
|
||||
title={isFullscreen ? t('exitFullscreen', language) : t('fullscreen', language)}
|
||||
>
|
||||
@@ -375,7 +375,7 @@ function TradingViewChartComponent({
|
||||
ref={containerRef}
|
||||
style={{
|
||||
height: isFullscreen ? 'calc(100vh - 65px)' : height,
|
||||
background: '#0B0E11',
|
||||
background: '#F1ECE2',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
@@ -394,5 +394,5 @@ function TradingViewChartComponent({
|
||||
)
|
||||
}
|
||||
|
||||
// 使用 memo 避免不必要的重渲染
|
||||
// Use memo to avoid unnecessary re-renders
|
||||
export const TradingViewChart = memo(TradingViewChartComponent)
|
||||
|
||||
@@ -5,16 +5,16 @@ interface ContainerProps {
|
||||
className?: string
|
||||
as?: 'div' | 'main' | 'header' | 'section'
|
||||
style?: CSSProperties
|
||||
/** 是否充满宽度(取消 max-width) */
|
||||
/** Whether to fill the full width (removes max-width) */
|
||||
fluid?: boolean
|
||||
/** 是否取消水平内边距 */
|
||||
/** Whether to remove horizontal padding */
|
||||
noPadding?: boolean
|
||||
/** 自定义最大宽度类(默认 max-w-[1920px]) */
|
||||
/** Custom max-width class (default max-w-[1920px]) */
|
||||
maxWidthClass?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的容器组件,确保所有页面元素使用一致的最大宽度和内边距
|
||||
* Unified container component that ensures all page elements use a consistent max width and padding
|
||||
* - max-width: 1920px
|
||||
* - padding: 24px (mobile) -> 32px (tablet) -> 48px (desktop)
|
||||
*/
|
||||
|
||||
@@ -9,32 +9,17 @@ interface DeepVoidBackgroundProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
export function DeepVoidBackground({ children, className = '', disableAnimation = false, ...props }: DeepVoidBackgroundProps) {
|
||||
return (
|
||||
<div className={`relative w-full min-h-screen bg-nofx-bg text-nofx-text overflow-hidden flex flex-col ${className}`} {...props}>
|
||||
{/* Background layers: use a much lighter static stack when animations are disabled */}
|
||||
{/* Background layers: neutralized to a plain cream surface for the light theme */}
|
||||
{disableAnimation ? (
|
||||
<>
|
||||
<div className="absolute inset-0 pointer-events-none z-0 bg-[radial-gradient(circle_at_top,rgba(240,185,11,0.08),transparent_38%),linear-gradient(180deg,rgba(12,14,20,0.98),rgba(8,10,15,1))]"></div>
|
||||
<div className="absolute inset-0 pointer-events-none z-0 opacity-[0.035] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:36px_36px]"></div>
|
||||
<div className="absolute inset-0 pointer-events-none z-0 bg-nofx-bg"></div>
|
||||
<div className="absolute inset-0 pointer-events-none z-0 opacity-[0.035] bg-[linear-gradient(to_right,rgba(26,24,19,0.08)_1px,transparent_1px),linear-gradient(to_bottom,rgba(26,24,19,0.08)_1px,transparent_1px)] bg-[size:36px_36px]"></div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 1. Grain/Noise Texture */}
|
||||
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20 mix-blend-soft-light pointer-events-none fixed z-0"></div>
|
||||
|
||||
{/* 2. Grid System */}
|
||||
{/* Faint grid system on cream */}
|
||||
<div className="absolute inset-0 pointer-events-none fixed z-0">
|
||||
<div className="absolute inset-x-0 bottom-0 h-[50vh] bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:40px_40px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)] opacity-50" style={{ transform: 'perspective(500px) rotateX(60deg) translateY(100px) scale(2)' }}></div>
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-[0.03]"></div>
|
||||
</div>
|
||||
|
||||
{/* 3. Ambient Glow Spots */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none fixed z-0">
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40vw] h-[40vw] bg-nofx-gold/10 rounded-full blur-[120px] mix-blend-screen animate-pulse-slow"></div>
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40vw] h-[40vw] bg-nofx-accent/5 rounded-full blur-[120px] mix-blend-screen animate-pulse-slow" style={{ animationDelay: '2s' }}></div>
|
||||
</div>
|
||||
|
||||
{/* 4. CRT/Scanline Overlay */}
|
||||
<div className="absolute inset-0 pointer-events-none fixed z-[9999] opacity-40">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(18,16,16,0)_50%,rgba(0,0,0,0.25)_50%),linear-gradient(90deg,rgba(255,0,0,0.06),rgba(0,255,0,0.02),rgba(0,0,255,0.06))] bg-[length:100%_4px,3px_100%] pointer-events-none"></div>
|
||||
<div className="absolute inset-x-0 bottom-0 h-[50vh] bg-[linear-gradient(to_right,rgba(26,24,19,0.07)_1px,transparent_1px),linear-gradient(to_bottom,rgba(26,24,19,0.07)_1px,transparent_1px)] bg-[size:40px_40px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_0%,#000_70%,transparent_100%)] opacity-50" style={{ transform: 'perspective(500px) rotateX(60deg) translateY(100px) scale(2)' }}></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ interface IconProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
// 本地图标路径映射
|
||||
// Local icon path mapping
|
||||
const ICON_PATHS: Record<string, string> = {
|
||||
binance: '/exchange-icons/binance.jpg',
|
||||
bybit: '/exchange-icons/bybit.png',
|
||||
@@ -20,7 +20,7 @@ const ICON_PATHS: Record<string, string> = {
|
||||
indodax: '/exchange-icons/indodax.png',
|
||||
}
|
||||
|
||||
// 通用图标组件
|
||||
// Generic icon component
|
||||
const ExchangeImage: React.FC<IconProps & { src: string; alt: string }> = ({
|
||||
width = 24,
|
||||
height = 24,
|
||||
@@ -36,7 +36,7 @@ const ExchangeImage: React.FC<IconProps & { src: string; alt: string }> = ({
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
background: '#2B3139',
|
||||
background: '#E8E2D5',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
@@ -51,7 +51,7 @@ const ExchangeImage: React.FC<IconProps & { src: string; alt: string }> = ({
|
||||
</div>
|
||||
)
|
||||
|
||||
// Fallback 图标
|
||||
// Fallback icon
|
||||
const FallbackIcon: React.FC<IconProps & { label: string }> = ({
|
||||
width = 24,
|
||||
height = 24,
|
||||
@@ -64,13 +64,13 @@ const FallbackIcon: React.FC<IconProps & { label: string }> = ({
|
||||
width,
|
||||
height,
|
||||
borderRadius: 6,
|
||||
background: '#2B3139',
|
||||
background: '#E8E2D5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: Math.max(10, (width || 24) * 0.4),
|
||||
fontWeight: 'bold',
|
||||
color: '#EAECEF',
|
||||
color: '#1A1813',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
@@ -78,7 +78,7 @@ const FallbackIcon: React.FC<IconProps & { label: string }> = ({
|
||||
</div>
|
||||
)
|
||||
|
||||
// 获取交易所图标的函数
|
||||
// Returns the icon for an exchange
|
||||
export const getExchangeIcon = (
|
||||
exchangeType: string,
|
||||
props: IconProps = {}
|
||||
|
||||
@@ -19,11 +19,11 @@ export function Header({ simple = false }: HeaderProps) {
|
||||
<img src="/icons/nofx.svg" alt="NoFx Logo" className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
|
||||
<h1 className="text-xl font-bold" style={{ color: '#1A1813' }}>
|
||||
{t('appTitle', language)}
|
||||
</h1>
|
||||
{!simple && (
|
||||
<p className="text-xs mono" style={{ color: '#848E9C' }}>
|
||||
<p className="text-xs mono" style={{ color: '#8A8478' }}>
|
||||
{t('subtitle', language)}
|
||||
</p>
|
||||
)}
|
||||
@@ -33,26 +33,26 @@ export function Header({ simple = false }: HeaderProps) {
|
||||
{/* Right - Language Toggle (always show) */}
|
||||
<div
|
||||
className="flex gap-1 rounded p-1"
|
||||
style={{ background: '#1E2329' }}
|
||||
style={{ background: '#E8E2D5' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => setLanguage('zh')}
|
||||
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
|
||||
style={
|
||||
language === 'zh'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
? { background: '#E0483B', color: '#F1ECE2' }
|
||||
: { background: 'transparent', color: '#8A8478' }
|
||||
}
|
||||
>
|
||||
中文
|
||||
Chinese
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLanguage('en')}
|
||||
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
|
||||
style={
|
||||
language === 'en'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
? { background: '#E0483B', color: '#F1ECE2' }
|
||||
: { background: 'transparent', color: '#8A8478' }
|
||||
}
|
||||
>
|
||||
EN
|
||||
@@ -62,8 +62,8 @@ export function Header({ simple = false }: HeaderProps) {
|
||||
className="px-3 py-1.5 rounded text-xs font-semibold transition-all"
|
||||
style={
|
||||
language === 'id'
|
||||
? { background: '#F0B90B', color: '#000' }
|
||||
: { background: 'transparent', color: '#848E9C' }
|
||||
? { background: '#E0483B', color: '#F1ECE2' }
|
||||
: { background: 'transparent', color: '#8A8478' }
|
||||
}
|
||||
>
|
||||
ID
|
||||
|
||||
@@ -30,8 +30,7 @@ export default function HeaderBar({
|
||||
isLoggedIn = false,
|
||||
isHomePage = false,
|
||||
currentPage,
|
||||
language = 'zh' as Language,
|
||||
onLanguageChange,
|
||||
language = 'en' as Language,
|
||||
user,
|
||||
onLogout,
|
||||
onPageChange,
|
||||
@@ -40,12 +39,10 @@ export default function HeaderBar({
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false)
|
||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false)
|
||||
const [userMode, setUserModeState] = useState<UserMode>(
|
||||
() => getUserMode() ?? 'advanced'
|
||||
)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const userDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const resolvedCurrentPage =
|
||||
currentPage ?? getCurrentPageForPath(location.pathname)
|
||||
@@ -63,12 +60,6 @@ export default function HeaderBar({
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setLanguageDropdownOpen(false)
|
||||
}
|
||||
if (
|
||||
userDropdownRef.current &&
|
||||
!userDropdownRef.current.contains(event.target as Node)
|
||||
@@ -93,8 +84,10 @@ export default function HeaderBar({
|
||||
}}
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
>
|
||||
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-7 h-7" />
|
||||
<span className="text-lg font-bold text-nofx-gold">NOFX</span>
|
||||
<span className="flex items-center justify-center w-8 h-8 rounded-md overflow-hidden shrink-0" style={{ background: '#fff', border: '1px solid rgba(26,24,19,0.12)' }}>
|
||||
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
|
||||
</span>
|
||||
<span className="text-lg font-bold text-nofx-gold tracking-wide">NOFX</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
@@ -117,7 +110,7 @@ export default function HeaderBar({
|
||||
path: ROUTES.data,
|
||||
label:
|
||||
language === 'zh'
|
||||
? '数据'
|
||||
? 'Data'
|
||||
: language === 'id'
|
||||
? 'Data'
|
||||
: 'Data',
|
||||
@@ -128,7 +121,7 @@ export default function HeaderBar({
|
||||
path: ROUTES.strategyMarket,
|
||||
label:
|
||||
language === 'zh'
|
||||
? '策略市场'
|
||||
? 'Market'
|
||||
: language === 'id'
|
||||
? 'Pasar'
|
||||
: 'Market',
|
||||
@@ -201,6 +194,8 @@ export default function HeaderBar({
|
||||
</button>
|
||||
))
|
||||
})()}
|
||||
{/* Dashboard context slot — terminal selector + status portals in here */}
|
||||
<div id="dash-header-slot" className="hidden lg:flex items-center" />
|
||||
</div>
|
||||
|
||||
{/* Right Side - Social Links and User Actions */}
|
||||
@@ -216,7 +211,7 @@ export default function HeaderBar({
|
||||
href={OFFICIAL_LINKS.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-white hover:bg-white/5"
|
||||
className="p-2 rounded-lg transition-all hover:scale-110 text-nofx-text-muted hover:text-nofx-text hover:bg-[rgba(26,24,19,0.06)]"
|
||||
title="GitHub"
|
||||
>
|
||||
<svg
|
||||
@@ -265,7 +260,7 @@ export default function HeaderBar({
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-5 w-px" style={{ background: '#2B3139' }} />
|
||||
<div className="h-5 w-px" style={{ background: 'rgba(26,24,19,0.15)' }} />
|
||||
|
||||
{/* User Info and Actions */}
|
||||
{isLoggedIn && user ? (
|
||||
@@ -274,9 +269,9 @@ export default function HeaderBar({
|
||||
<div className="relative" ref={userDropdownRef}>
|
||||
<button
|
||||
onClick={() => setUserDropdownOpen(!userDropdownOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded transition-colors bg-nofx-bg-lighter border border-nofx-gold/20 hover:bg-white/5"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded transition-colors bg-nofx-bg-lighter border border-nofx-gold/20 hover:bg-[rgba(26,24,19,0.06)]"
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold bg-nofx-gold text-black">
|
||||
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold bg-nofx-gold text-white">
|
||||
{user.email[0].toUpperCase()}
|
||||
</div>
|
||||
<span className="text-sm text-nofx-text-muted">
|
||||
@@ -300,7 +295,7 @@ export default function HeaderBar({
|
||||
navigateInApp(ROUTES.settings)
|
||||
setUserDropdownOpen(false)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-white/5 text-nofx-text-muted hover:text-white"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-[rgba(26,24,19,0.06)] text-nofx-text-muted hover:text-nofx-text"
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
Settings
|
||||
@@ -311,15 +306,15 @@ export default function HeaderBar({
|
||||
userMode === 'beginner' ? 'advanced' : 'beginner'
|
||||
)
|
||||
}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-white/5 text-nofx-text-muted hover:text-white"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-[rgba(26,24,19,0.06)] text-nofx-text-muted hover:text-nofx-text"
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
{userMode === 'beginner'
|
||||
? language === 'zh'
|
||||
? '切到老手模式'
|
||||
? 'Switch to Advanced'
|
||||
: 'Switch to Advanced'
|
||||
: language === 'zh'
|
||||
? '切到新手模式'
|
||||
? 'Switch to Beginner'
|
||||
: 'Switch to Beginner'}
|
||||
</button>
|
||||
{onLogout && (
|
||||
@@ -345,7 +340,7 @@ export default function HeaderBar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigateInApp(ROUTES.login)}
|
||||
className="px-3 py-2 text-sm font-medium transition-colors rounded text-nofx-text-muted hover:text-white"
|
||||
className="px-3 py-2 text-sm font-medium transition-colors rounded text-nofx-text-muted hover:text-nofx-text"
|
||||
>
|
||||
{t('signIn', language)}
|
||||
</button>
|
||||
@@ -353,63 +348,14 @@ export default function HeaderBar({
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Language Toggle - Always at the rightmost */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setLanguageDropdownOpen(!languageDropdownOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded transition-colors text-nofx-text-muted hover:bg-white/5"
|
||||
>
|
||||
<span className="text-lg">
|
||||
{language === 'zh' ? '🇨🇳' : language === 'id' ? '🇮🇩' : '🇺🇸'}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{languageDropdownOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-32 rounded-lg shadow-lg overflow-hidden z-50 bg-nofx-bg-lighter border border-nofx-gold/20">
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('zh')
|
||||
setLanguageDropdownOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors text-nofx-text-muted hover:text-white
|
||||
${language === 'zh' ? 'bg-nofx-gold/10' : 'hover:bg-white/5'}`}
|
||||
>
|
||||
<span className="text-base">🇨🇳</span>
|
||||
<span className="text-sm">中文</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('en')
|
||||
setLanguageDropdownOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors text-nofx-text-muted hover:text-white
|
||||
${language === 'en' ? 'bg-nofx-gold/10' : 'hover:bg-white/5'}`}
|
||||
>
|
||||
<span className="text-base">🇺🇸</span>
|
||||
<span className="text-sm">English</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLanguageChange?.('id')
|
||||
setLanguageDropdownOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 transition-colors text-nofx-text-muted hover:text-white
|
||||
${language === 'id' ? 'bg-nofx-gold/10' : 'hover:bg-white/5'}`}
|
||||
>
|
||||
<span className="text-base">🇮🇩</span>
|
||||
<span className="text-sm">Bahasa</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Language switcher removed — the product UI is English-only. */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<motion.button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="md:hidden text-nofx-text-muted hover:text-white"
|
||||
className="md:hidden text-nofx-text-muted hover:text-nofx-text"
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
@@ -453,7 +399,7 @@ export default function HeaderBar({
|
||||
path: ROUTES.data,
|
||||
label:
|
||||
language === 'zh'
|
||||
? '数据'
|
||||
? 'Data'
|
||||
: language === 'id'
|
||||
? 'Data'
|
||||
: 'Data',
|
||||
@@ -464,7 +410,7 @@ export default function HeaderBar({
|
||||
path: ROUTES.strategyMarket,
|
||||
label:
|
||||
language === 'zh'
|
||||
? '策略市场'
|
||||
? 'Market'
|
||||
: language === 'id'
|
||||
? 'Pasar'
|
||||
: 'Market',
|
||||
@@ -615,28 +561,8 @@ export default function HeaderBar({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Account / Lang */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Lang Switcher */}
|
||||
<div className="flex bg-zinc-900 rounded-lg p-1 border border-zinc-800">
|
||||
{['zh', 'en', 'id'].map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => {
|
||||
onLanguageChange?.(lang as Language)
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`flex-1 py-3 text-sm font-bold rounded-md transition-colors ${
|
||||
language === lang
|
||||
? 'bg-zinc-800 text-white shadow-sm'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
{lang === 'zh' ? 'CN' : lang === 'id' ? 'ID' : 'EN'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Account (language switcher removed — English-only UI) */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{/* Auth Actions */}
|
||||
{isLoggedIn && user ? (
|
||||
<button
|
||||
@@ -657,7 +583,7 @@ export default function HeaderBar({
|
||||
navigateInApp(ROUTES.login)
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="flex items-center justify-center bg-nofx-gold text-black rounded-lg font-bold text-sm hover:bg-yellow-400 transition-colors"
|
||||
className="flex items-center justify-center bg-nofx-gold text-white rounded-lg font-bold text-sm hover:opacity-90 transition-colors"
|
||||
>
|
||||
{t('signIn', language)}
|
||||
</button>
|
||||
|
||||
@@ -76,7 +76,7 @@ function buildAgentName(nowMs: number) {
|
||||
return `${AGENT_NAME} valid_until ${nowMs + AGENT_VALIDITY_MS}`
|
||||
}
|
||||
const HYPERLIQUID_BUILDER_ADDRESS = '0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d'
|
||||
// 0.05% (万5). Must match the server's defaultHyperliquidBuilderMaxFee and
|
||||
// 0.05% (5 bps). Must match the server's defaultHyperliquidBuilderMaxFee and
|
||||
// the BuilderInfo.Fee=50 (= 5 bps) used at order placement. The user signs
|
||||
// this exact string when approving the builder during wallet connect.
|
||||
const HYPERLIQUID_BUILDER_MAX_FEE = '0.05%'
|
||||
@@ -131,7 +131,7 @@ function getPreferredWalletProvider(): WalletProvider | undefined {
|
||||
|
||||
function walletSupportLabel(language: Language) {
|
||||
return language === 'zh'
|
||||
? '支持 MetaMask、Rabby、Coinbase、Phantom、Brave、Backpack、OKX、Trust 等 EVM 钱包。'
|
||||
? 'Supports MetaMask, Rabby, Coinbase, Phantom, Brave, Backpack, OKX, Trust and other EVM wallets.'
|
||||
: 'Supports MetaMask, Rabby, Coinbase Wallet, Phantom, Brave, Backpack, OKX, Trust and other EVM wallets.'
|
||||
}
|
||||
|
||||
@@ -239,55 +239,64 @@ export function HyperliquidWalletConnect({
|
||||
const [agentInfo, setAgentInfo] = useState<HyperliquidAgentInfo | null>(null)
|
||||
const [agentInfoLoading, setAgentInfoLoading] = useState(false)
|
||||
const [hasWalletProvider, setHasWalletProvider] = useState(false)
|
||||
// Address of a fully-authorized hyperliquid exchange saved on the SERVER.
|
||||
// The local FlowState lives in this browser's localStorage, so a fresh
|
||||
// browser would otherwise show the red "Connect" CTA even though trading
|
||||
// authorization is complete and the bot is running.
|
||||
const [serverExchangeAddr, setServerExchangeAddr] = useState('')
|
||||
const text = useMemo(
|
||||
() => ({
|
||||
title: language === 'zh' ? 'Hyperliquid 钱包' : 'Hyperliquid Wallet',
|
||||
connect: language === 'zh' ? '连接 Hyperliquid' : 'Connect Hyperliquid',
|
||||
connected: language === 'zh' ? '已连接' : 'Connected',
|
||||
mainWallet: language === 'zh' ? 'EVM 主钱包' : 'EVM main wallet',
|
||||
title: language === 'zh' ? 'Hyperliquid Wallet' : 'Hyperliquid Wallet',
|
||||
connect: language === 'zh' ? 'Connect Hyperliquid' : 'Connect Hyperliquid',
|
||||
connected: language === 'zh' ? 'Connected' : 'Connected',
|
||||
mainWallet: language === 'zh' ? 'Connect your wallet' : 'Connect your wallet',
|
||||
generateAgent:
|
||||
language === 'zh'
|
||||
? '生成 NOFX Agent 钱包'
|
||||
: 'Generate NOFX agent wallet',
|
||||
? 'Create a trading key for NOFX'
|
||||
: 'Create a trading key for NOFX',
|
||||
approveAgent:
|
||||
language === 'zh' ? '授权 Agent 交易' : 'Authorize agent trading',
|
||||
language === 'zh'
|
||||
? 'Approve it in your wallet (trade-only, cannot withdraw)'
|
||||
: 'Approve it in your wallet (trade-only, cannot withdraw)',
|
||||
approveBuilder:
|
||||
language === 'zh' ? '完成交易授权' : 'Finalize trading authorization',
|
||||
save: language === 'zh' ? '保存到 NOFX' : 'Save to NOFX',
|
||||
done: language === 'zh' ? '流程已完成' : 'Flow complete',
|
||||
balance: language === 'zh' ? 'Hyperliquid 余额' : 'Hyperliquid balance',
|
||||
withdrawable: language === 'zh' ? '可用' : 'Withdrawable',
|
||||
equity: language === 'zh' ? '权益' : 'Equity',
|
||||
marginUsed: language === 'zh' ? '已用保证金' : 'Margin used',
|
||||
unrealizedPnl: language === 'zh' ? '未实现盈亏' : 'Unrealized PnL',
|
||||
refresh: language === 'zh' ? '刷新' : 'Refresh',
|
||||
language === 'zh'
|
||||
? 'Approve the small per-trade builder fee'
|
||||
: 'Approve the small per-trade builder fee',
|
||||
save: language === 'zh' ? 'Save to NOFX — done' : 'Save to NOFX — done',
|
||||
done: language === 'zh' ? 'All set — trading authorized' : 'All set — trading authorized',
|
||||
balance: language === 'zh' ? 'Hyperliquid balance' : 'Hyperliquid balance',
|
||||
withdrawable: language === 'zh' ? 'Withdrawable' : 'Withdrawable',
|
||||
equity: language === 'zh' ? 'Equity' : 'Equity',
|
||||
marginUsed: language === 'zh' ? 'Margin used' : 'Margin used',
|
||||
unrealizedPnl: language === 'zh' ? 'Unrealized PnL' : 'Unrealized PnL',
|
||||
refresh: language === 'zh' ? 'Refresh' : 'Refresh',
|
||||
noCustody:
|
||||
language === 'zh'
|
||||
? '资金保留在你的 Hyperliquid 账户;NOFX 只保存已授权 Agent 钱包。'
|
||||
? 'Funds stay in your Hyperliquid account; NOFX only stores the authorized agent wallet.'
|
||||
: 'Funds stay in your Hyperliquid account; NOFX only stores the authorized agent wallet.',
|
||||
agentExpiry:
|
||||
language === 'zh' ? 'Agent 授权到期' : 'Agent authorization expires',
|
||||
agentExpired: language === 'zh' ? '已过期' : 'Expired',
|
||||
language === 'zh' ? 'Agent authorization expires' : 'Agent authorization expires',
|
||||
agentExpired: language === 'zh' ? 'Expired' : 'Expired',
|
||||
agentNoAuth:
|
||||
language === 'zh'
|
||||
? '未检测到 NOFX Agent 授权'
|
||||
? 'No NOFX agent authorization found'
|
||||
: 'No NOFX agent authorization found',
|
||||
renewAgent:
|
||||
language === 'zh'
|
||||
? '续期 Agent 授权(+180 天)'
|
||||
? 'Renew agent authorization (+180d)'
|
||||
: 'Renew agent authorization (+180d)',
|
||||
renewHint:
|
||||
language === 'zh'
|
||||
? 'Hyperliquid 不允许重复使用同一个 Agent,续期会生成一个新的 Agent 并以 180 天有效期授权,然后自动更新 NOFX 保存的私钥(需登录)。'
|
||||
? 'Hyperliquid forbids reusing an agent, so renewal creates a new agent approved for 180 days, then updates the stored key in NOFX (sign-in required).'
|
||||
: 'Hyperliquid forbids reusing an agent, so renewal creates a new agent approved for 180 days, then updates the stored key in NOFX (sign-in required).',
|
||||
noWalletTitle:
|
||||
language === 'zh' ? '未检测到 EVM 钱包' : 'No EVM wallet detected',
|
||||
language === 'zh' ? 'No EVM wallet detected' : 'No EVM wallet detected',
|
||||
noWalletDetail:
|
||||
language === 'zh'
|
||||
? '先安装 Rabby 或 MetaMask,创建或导入钱包,然后回到这里连接 Hyperliquid。'
|
||||
? 'Install Rabby or MetaMask, create or import a wallet, then return here to connect Hyperliquid.'
|
||||
: 'Install Rabby or MetaMask, create or import a wallet, then return here to connect Hyperliquid.',
|
||||
installRabby: language === 'zh' ? '安装 Rabby' : 'Install Rabby',
|
||||
installMetaMask: language === 'zh' ? '安装 MetaMask' : 'Install MetaMask',
|
||||
installRabby: language === 'zh' ? 'Install Rabby' : 'Install Rabby',
|
||||
installMetaMask: language === 'zh' ? 'Install MetaMask' : 'Install MetaMask',
|
||||
}),
|
||||
[language]
|
||||
)
|
||||
@@ -300,6 +309,31 @@ export function HyperliquidWalletConnect({
|
||||
saveState(state)
|
||||
}, [state])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) {
|
||||
setServerExchangeAddr('')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
api
|
||||
.getExchangeConfigs()
|
||||
.then((configs) => {
|
||||
if (cancelled) return
|
||||
const ready = configs.find(
|
||||
(exchange) =>
|
||||
exchange.exchange_type === 'hyperliquid' &&
|
||||
exchange.enabled &&
|
||||
Boolean(exchange.hyperliquidBuilderApproved) &&
|
||||
(exchange.hyperliquidWalletAddr || '').trim() !== ''
|
||||
)
|
||||
setServerExchangeAddr(ready?.hyperliquidWalletAddr || '')
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isLoggedIn])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn || !state.mainWallet) return
|
||||
let cancelled = false
|
||||
@@ -493,6 +527,11 @@ export function HyperliquidWalletConnect({
|
||||
const complete = Boolean(
|
||||
state.mainWallet && state.savedExchangeId && state.builderApproved
|
||||
)
|
||||
// Trigger shows "connected" when either this browser finished the flow or
|
||||
// the server already holds a fully-authorized exchange.
|
||||
const connectedAddr = complete
|
||||
? state.mainWallet
|
||||
: serverExchangeAddr || undefined
|
||||
|
||||
async function connectWallet() {
|
||||
setError('')
|
||||
@@ -500,7 +539,7 @@ export function HyperliquidWalletConnect({
|
||||
if (!provider) {
|
||||
setError(
|
||||
language === 'zh'
|
||||
? '未检测到 EVM 钱包,请安装 MetaMask / Rabby / OKX / Coinbase Wallet。'
|
||||
? 'No EVM wallet detected. Install MetaMask, Rabby, OKX or Coinbase Wallet.'
|
||||
: 'No EVM wallet detected. Install MetaMask, Rabby, OKX or Coinbase Wallet.'
|
||||
)
|
||||
return
|
||||
@@ -625,7 +664,7 @@ export function HyperliquidWalletConnect({
|
||||
if (!isLoggedIn) {
|
||||
setError(
|
||||
language === 'zh'
|
||||
? '续期需要先登录 NOFX:Hyperliquid 不允许重复使用同一个 Agent,续期会生成新的 Agent 并更新已保存的私钥。'
|
||||
? 'Renewal requires signing in: Hyperliquid forbids reusing the same agent, so renewal creates a new agent and updates the stored key.'
|
||||
: 'Renewal requires signing in: Hyperliquid forbids reusing the same agent, so renewal creates a new agent and updates the stored key.'
|
||||
)
|
||||
return
|
||||
@@ -680,7 +719,7 @@ export function HyperliquidWalletConnect({
|
||||
}))
|
||||
throw new Error(
|
||||
language === 'zh'
|
||||
? '新 Agent 已授权,但未找到对应的 NOFX 配置,请用“保存到 NOFX”手动保存。'
|
||||
? 'New agent approved, but no matching NOFX config was found. Use "Save to NOFX" to store it.'
|
||||
: 'New agent approved, but no matching NOFX config was found. Use "Save to NOFX" to store it.'
|
||||
)
|
||||
}
|
||||
@@ -712,7 +751,7 @@ export function HyperliquidWalletConnect({
|
||||
}))
|
||||
toast.success(
|
||||
language === 'zh'
|
||||
? 'Agent 已续期(新 Agent,有效期 180 天)'
|
||||
? 'Agent renewed (new agent, valid 180 days)'
|
||||
: 'Agent renewed (new agent, valid 180 days)'
|
||||
)
|
||||
await refreshAgentInfo()
|
||||
@@ -721,7 +760,7 @@ export function HyperliquidWalletConnect({
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: language === 'zh'
|
||||
? 'Agent 续期失败'
|
||||
? 'Agent renewal failed'
|
||||
: 'Agent renewal failed'
|
||||
)
|
||||
} finally {
|
||||
@@ -773,14 +812,14 @@ export function HyperliquidWalletConnect({
|
||||
: undefined,
|
||||
}))
|
||||
toast.success(
|
||||
language === 'zh' ? '交易授权已完成' : 'Trading authorization finalized'
|
||||
language === 'zh' ? 'Trading authorization finalized' : 'Trading authorization finalized'
|
||||
)
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: language === 'zh'
|
||||
? '交易授权失败'
|
||||
? 'Trading authorization failed'
|
||||
: 'Trading authorization failed'
|
||||
)
|
||||
} finally {
|
||||
@@ -793,7 +832,7 @@ export function HyperliquidWalletConnect({
|
||||
if (!isLoggedIn) {
|
||||
setError(
|
||||
language === 'zh'
|
||||
? '请先登录 NOFX,再保存 Agent 钱包用于交易。'
|
||||
? 'Please sign in before saving the agent wallet for trading.'
|
||||
: 'Please sign in before saving the agent wallet for trading.'
|
||||
)
|
||||
return
|
||||
@@ -898,14 +937,14 @@ export function HyperliquidWalletConnect({
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-bold transition-all border ${
|
||||
complete
|
||||
? 'bg-emerald-500/10 border-emerald-400/30 text-emerald-300'
|
||||
connectedAddr
|
||||
? 'bg-nofx-success/10 border-nofx-success/30 text-nofx-success'
|
||||
: 'bg-nofx-gold/10 border-nofx-gold/30 text-nofx-gold hover:bg-nofx-gold/20'
|
||||
}`}
|
||||
>
|
||||
<Wallet className="w-4 h-4" />
|
||||
<span>
|
||||
{complete ? shortAddress(state.mainWallet) : text.connect}
|
||||
{connectedAddr ? shortAddress(connectedAddr) : text.connect}
|
||||
</span>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -913,11 +952,11 @@ export function HyperliquidWalletConnect({
|
||||
|
||||
{(open || inline) && (
|
||||
<div
|
||||
className={`${inline ? 'relative w-full' : 'absolute right-0 top-full mt-2 w-[420px] shadow-2xl shadow-black/50'} rounded-2xl border border-nofx-gold/20 bg-[#11151B] z-[80] overflow-hidden`}
|
||||
className={`${inline ? 'relative w-full' : 'absolute right-0 top-full mt-2 w-[420px] shadow-2xl shadow-black/10'} rounded-2xl border border-[rgba(26,24,19,0.14)] bg-nofx-bg-lighter z-[80] overflow-hidden`}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[rgba(26,24,19,0.14)]">
|
||||
<div>
|
||||
<div className="font-bold text-white">{text.title}</div>
|
||||
<div className="font-bold text-nofx-text">{text.title}</div>
|
||||
<div className="text-xs text-nofx-text-muted mt-1">
|
||||
{text.noCustody}
|
||||
</div>
|
||||
@@ -929,7 +968,7 @@ export function HyperliquidWalletConnect({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="p-1 rounded hover:bg-white/10 text-zinc-500"
|
||||
className="p-1 rounded hover:bg-[rgba(26,24,19,0.06)] text-nofx-text-muted"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -943,10 +982,10 @@ export function HyperliquidWalletConnect({
|
||||
<div
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||
step.status === 'done'
|
||||
? 'bg-emerald-400 text-black'
|
||||
? 'bg-nofx-success text-white'
|
||||
: step.status === 'active'
|
||||
? 'bg-nofx-gold text-black'
|
||||
: 'bg-zinc-800 text-zinc-500'
|
||||
? 'bg-nofx-gold text-white'
|
||||
: 'bg-nofx-bg-deeper text-nofx-text-muted'
|
||||
}`}
|
||||
>
|
||||
{step.status === 'done' ? (
|
||||
@@ -958,8 +997,8 @@ export function HyperliquidWalletConnect({
|
||||
<span
|
||||
className={
|
||||
step.status === 'pending'
|
||||
? 'text-zinc-500'
|
||||
: 'text-zinc-200'
|
||||
? 'text-nofx-text-muted'
|
||||
: 'text-nofx-text'
|
||||
}
|
||||
>
|
||||
{step.label}
|
||||
@@ -969,14 +1008,14 @@ export function HyperliquidWalletConnect({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-300">
|
||||
<div className="rounded-lg border border-nofx-danger/30 bg-nofx-danger/10 p-3 text-xs text-nofx-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!state.mainWallet && !hasWalletProvider && (
|
||||
<div className="rounded-xl border border-nofx-gold/20 bg-nofx-gold/5 p-3">
|
||||
<div className="text-sm font-semibold text-zinc-100">
|
||||
<div className="text-sm font-semibold text-nofx-text">
|
||||
{text.noWalletTitle}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-nofx-text-muted">
|
||||
@@ -987,7 +1026,7 @@ export function HyperliquidWalletConnect({
|
||||
href="https://rabby.io/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-zinc-200 hover:border-white/20 hover:bg-white/[0.07]"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-[rgba(26,24,19,0.14)] bg-nofx-bg-deeper px-3 py-2 text-xs font-semibold text-nofx-text hover:border-[rgba(26,24,19,0.24)] hover:bg-nofx-bg"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{text.installRabby}
|
||||
@@ -996,7 +1035,7 @@ export function HyperliquidWalletConnect({
|
||||
href="https://metamask.io/download/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-zinc-200 hover:border-white/20 hover:bg-white/[0.07]"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-[rgba(26,24,19,0.14)] bg-nofx-bg-deeper px-3 py-2 text-xs font-semibold text-nofx-text hover:border-[rgba(26,24,19,0.24)] hover:bg-nofx-bg"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{text.installMetaMask}
|
||||
@@ -1005,14 +1044,14 @@ export function HyperliquidWalletConnect({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-black/25 p-3 space-y-2 text-xs">
|
||||
<div className="rounded-xl border border-[rgba(26,24,19,0.14)] bg-nofx-bg-deeper p-3 space-y-2 text-xs">
|
||||
{state.mainWallet && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-zinc-500">Main</span>
|
||||
<span className="text-nofx-text-muted">Main</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(state.mainWallet!, 'Main wallet')}
|
||||
className="font-mono text-zinc-200 hover:text-nofx-gold flex items-center gap-1"
|
||||
className="font-mono text-nofx-text hover:text-nofx-gold flex items-center gap-1"
|
||||
>
|
||||
{shortAddress(state.mainWallet)}{' '}
|
||||
<Copy className="w-3 h-3" />
|
||||
@@ -1021,11 +1060,11 @@ export function HyperliquidWalletConnect({
|
||||
)}
|
||||
{state.agentAddress && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-zinc-500">Agent</span>
|
||||
<span className="text-nofx-text-muted">Agent</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(state.agentAddress!, 'Agent wallet')}
|
||||
className="font-mono text-zinc-200 hover:text-nofx-gold flex items-center gap-1"
|
||||
className="font-mono text-nofx-text hover:text-nofx-gold flex items-center gap-1"
|
||||
>
|
||||
{shortAddress(state.agentAddress)}{' '}
|
||||
<Copy className="w-3 h-3" />
|
||||
@@ -1033,16 +1072,16 @@ export function HyperliquidWalletConnect({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-zinc-500">Network</span>
|
||||
<span className="font-mono text-zinc-300">
|
||||
<span className="text-nofx-text-muted">Network</span>
|
||||
<span className="font-mono text-nofx-text">
|
||||
Hyperliquid Mainnet
|
||||
</span>
|
||||
</div>
|
||||
{state.mainWallet && (
|
||||
<div className="flex items-center justify-between gap-3 border-t border-white/10 pt-2">
|
||||
<span className="text-zinc-500">{text.agentExpiry}</span>
|
||||
<div className="flex items-center justify-between gap-3 border-t border-[rgba(26,24,19,0.14)] pt-2">
|
||||
<span className="text-nofx-text-muted">{text.agentExpiry}</span>
|
||||
{agentInfoLoading && !agentInfo ? (
|
||||
<span className="font-mono text-zinc-400">Loading…</span>
|
||||
<span className="font-mono text-nofx-text-muted">Loading…</span>
|
||||
) : agentInfo ? (
|
||||
(() => {
|
||||
const { dateStr, daysLeft } = formatAgentExpiry(
|
||||
@@ -1052,10 +1091,10 @@ export function HyperliquidWalletConnect({
|
||||
const expired = daysLeft < 0
|
||||
const soon = daysLeft >= 0 && daysLeft <= 14
|
||||
const tone = expired
|
||||
? 'text-red-300'
|
||||
? 'text-nofx-danger'
|
||||
: soon
|
||||
? 'text-amber-300'
|
||||
: 'text-zinc-200'
|
||||
? 'text-nofx-gold'
|
||||
: 'text-nofx-text'
|
||||
return (
|
||||
<span className={`font-mono text-right ${tone}`}>
|
||||
{dateStr}
|
||||
@@ -1066,7 +1105,7 @@ export function HyperliquidWalletConnect({
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<span className="font-mono text-zinc-500">
|
||||
<span className="font-mono text-nofx-text-muted">
|
||||
{text.agentNoAuth}
|
||||
</span>
|
||||
)}
|
||||
@@ -1098,14 +1137,14 @@ export function HyperliquidWalletConnect({
|
||||
{state.mainWallet && (
|
||||
<div className="rounded-xl border border-nofx-gold/20 bg-nofx-gold/5 p-3 space-y-3 text-xs">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-bold text-zinc-100">
|
||||
<span className="font-bold text-nofx-text">
|
||||
{text.balance}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshBalance()}
|
||||
disabled={balanceLoading}
|
||||
className="flex items-center gap-1 text-zinc-400 hover:text-nofx-gold disabled:opacity-60"
|
||||
className="flex items-center gap-1 text-nofx-text-muted hover:text-nofx-gold disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-3 h-3 ${balanceLoading ? 'animate-spin' : ''}`}
|
||||
@@ -1114,37 +1153,37 @@ export function HyperliquidWalletConnect({
|
||||
</button>
|
||||
</div>
|
||||
{balanceError ? (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-2 text-red-300">
|
||||
<div className="rounded-lg border border-nofx-danger/30 bg-nofx-danger/10 p-2 text-nofx-danger">
|
||||
{balanceError}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-lg bg-black/25 p-2">
|
||||
<div className="text-zinc-500">{text.withdrawable}</div>
|
||||
<div className="mt-1 font-mono text-sm font-bold text-emerald-300">
|
||||
<div className="rounded-lg bg-nofx-bg-deeper p-2">
|
||||
<div className="text-nofx-text-muted">{text.withdrawable}</div>
|
||||
<div className="mt-1 font-mono text-sm font-bold text-nofx-success">
|
||||
{balanceLoading && !account
|
||||
? 'Loading…'
|
||||
: `${formatUSDC(account?.withdrawable)} USDC`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-black/25 p-2">
|
||||
<div className="text-zinc-500">{text.equity}</div>
|
||||
<div className="mt-1 font-mono text-sm font-bold text-zinc-100">
|
||||
<div className="rounded-lg bg-nofx-bg-deeper p-2">
|
||||
<div className="text-nofx-text-muted">{text.equity}</div>
|
||||
<div className="mt-1 font-mono text-sm font-bold text-nofx-text">
|
||||
{balanceLoading && !account
|
||||
? 'Loading…'
|
||||
: `${formatUSDC(account?.accountValue)} USDC`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-black/25 p-2">
|
||||
<div className="text-zinc-500">{text.marginUsed}</div>
|
||||
<div className="mt-1 font-mono text-sm font-bold text-zinc-100">
|
||||
<div className="rounded-lg bg-nofx-bg-deeper p-2">
|
||||
<div className="text-nofx-text-muted">{text.marginUsed}</div>
|
||||
<div className="mt-1 font-mono text-sm font-bold text-nofx-text">
|
||||
{formatUSDC(account?.totalMarginUsed)} USDC
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-black/25 p-2">
|
||||
<div className="text-zinc-500">{text.unrealizedPnl}</div>
|
||||
<div className="rounded-lg bg-nofx-bg-deeper p-2">
|
||||
<div className="text-nofx-text-muted">{text.unrealizedPnl}</div>
|
||||
<div
|
||||
className={`mt-1 font-mono text-sm font-bold ${(account?.unrealizedPnl ?? 0) >= 0 ? 'text-emerald-300' : 'text-red-300'}`}
|
||||
className={`mt-1 font-mono text-sm font-bold ${(account?.unrealizedPnl ?? 0) >= 0 ? 'text-nofx-success' : 'text-nofx-danger'}`}
|
||||
>
|
||||
{formatSignedUSDC(account?.unrealizedPnl)} USDC
|
||||
</div>
|
||||
@@ -1192,7 +1231,7 @@ export function HyperliquidWalletConnect({
|
||||
)}
|
||||
{complete && (
|
||||
<>
|
||||
<div className="rounded-lg border border-emerald-400/30 bg-emerald-500/10 p-3 text-sm text-emerald-200 flex items-center gap-2">
|
||||
<div className="rounded-lg border border-nofx-success/30 bg-nofx-success/10 p-3 text-sm text-nofx-success flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" /> {text.done}
|
||||
</div>
|
||||
<button
|
||||
@@ -1201,26 +1240,26 @@ export function HyperliquidWalletConnect({
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl border border-nofx-gold/30 bg-nofx-gold/10 px-4 py-3 text-sm font-bold text-nofx-gold transition hover:bg-nofx-gold/20"
|
||||
>
|
||||
{language === 'zh'
|
||||
? '重新授权交易'
|
||||
? 'Re-authorize trading'
|
||||
: 'Re-authorize trading'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-white/10">
|
||||
<div className="flex items-center justify-between pt-2 border-t border-[rgba(26,24,19,0.14)]">
|
||||
<a
|
||||
href="https://app.hyperliquid.xyz/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-zinc-500 hover:text-nofx-gold flex items-center gap-1"
|
||||
className="text-xs text-nofx-text-muted hover:text-nofx-gold flex items-center gap-1"
|
||||
>
|
||||
Open Hyperliquid <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetFlow}
|
||||
className="text-xs text-zinc-500 hover:text-red-300"
|
||||
className="text-xs text-nofx-text-muted hover:text-nofx-danger"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
@@ -1246,7 +1285,7 @@ function ActionButton({
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl bg-nofx-gold px-4 py-3 text-sm font-bold text-black transition hover:bg-yellow-400 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
className="w-full flex items-center justify-center gap-2 rounded-xl bg-nofx-gold px-4 py-3 text-sm font-bold text-white transition hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
{label}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLanguage } from '../../contexts/LanguageContext'
|
||||
import type { Language } from '../../i18n/translations'
|
||||
|
||||
const languages: { code: Language; label: string }[] = [
|
||||
{ code: 'zh', label: '中文' },
|
||||
{ code: 'zh', label: 'Chinese' },
|
||||
{ code: 'en', label: 'EN' },
|
||||
{ code: 'id', label: 'ID' },
|
||||
]
|
||||
@@ -12,8 +12,8 @@ export function LanguageSwitcher() {
|
||||
const { language, setLanguage } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="absolute top-4 right-4 z-50 flex items-center gap-1 rounded-lg p-1 border border-white/10 bg-white/5 backdrop-blur-sm">
|
||||
<Globe size={14} className="text-zinc-500 ml-1.5 mr-0.5" />
|
||||
<div className="absolute top-4 right-4 z-50 flex items-center gap-1 rounded-lg p-1 border border-[rgba(26,24,19,0.14)] bg-nofx-bg-lighter backdrop-blur-sm">
|
||||
<Globe size={14} className="text-nofx-text-muted ml-1.5 mr-0.5" />
|
||||
{languages.map(({ code, label }) => (
|
||||
<button
|
||||
key={code}
|
||||
@@ -22,7 +22,7 @@ export function LanguageSwitcher() {
|
||||
className={`px-2.5 py-1 rounded text-xs font-semibold transition-all ${
|
||||
language === code
|
||||
? 'bg-nofx-gold/15 text-nofx-gold'
|
||||
: 'text-zinc-500 hover:text-zinc-300 bg-transparent'
|
||||
: 'text-nofx-text-muted hover:text-nofx-text bg-transparent'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -19,122 +19,122 @@ export const METRIC_DEFINITIONS: Record<string, MetricDefinition> = {
|
||||
total_return: {
|
||||
key: 'total_return',
|
||||
nameEn: 'Total Return',
|
||||
nameZh: '总收益率',
|
||||
nameZh: 'Total Return',
|
||||
formula: 'R_{total} = \\frac{V_{end} - V_{start}}{V_{start}} \\times 100\\%',
|
||||
descriptionEn: 'Measures overall portfolio performance from start to end',
|
||||
descriptionZh: '衡量投资组合从开始到结束的整体收益表现',
|
||||
descriptionZh: 'Measures overall portfolio performance from start to end',
|
||||
},
|
||||
annualized_return: {
|
||||
key: 'annualized_return',
|
||||
nameEn: 'Annualized Return',
|
||||
nameZh: '年化收益率',
|
||||
nameZh: 'Annualized Return',
|
||||
formula: 'R_{ann} = \\left(1 + R_{total}\\right)^{\\frac{252}{n}} - 1',
|
||||
descriptionEn: 'Standardized yearly return rate (252 trading days)',
|
||||
descriptionZh: '标准化年度收益率(按252个交易日计算)',
|
||||
descriptionZh: 'Standardized yearly return rate (252 trading days)',
|
||||
},
|
||||
max_drawdown: {
|
||||
key: 'max_drawdown',
|
||||
nameEn: 'Maximum Drawdown',
|
||||
nameZh: '最大回撤',
|
||||
nameZh: 'Maximum Drawdown',
|
||||
formula: 'MDD = \\max_{t} \\left( \\frac{Peak_t - Trough_t}{Peak_t} \\right)',
|
||||
descriptionEn: 'Largest peak-to-trough decline during the period',
|
||||
descriptionZh: '期间内从峰值到谷底的最大跌幅',
|
||||
descriptionZh: 'Largest peak-to-trough decline during the period',
|
||||
},
|
||||
sharpe_ratio: {
|
||||
key: 'sharpe_ratio',
|
||||
nameEn: 'Sharpe Ratio',
|
||||
nameZh: '夏普比率',
|
||||
nameZh: 'Sharpe Ratio',
|
||||
formula: 'SR = \\frac{\\bar{r} - r_f}{\\sigma}',
|
||||
descriptionEn: 'Risk-adjusted return per unit of volatility (r̄=avg return, rf=risk-free rate, σ=std dev)',
|
||||
descriptionZh: '单位波动风险下的超额收益(r̄=平均收益,rf=无风险利率,σ=标准差)',
|
||||
descriptionZh: 'Risk-adjusted return per unit of volatility (r̄=avg return, rf=risk-free rate, σ=std dev)',
|
||||
},
|
||||
sortino_ratio: {
|
||||
key: 'sortino_ratio',
|
||||
nameEn: 'Sortino Ratio',
|
||||
nameZh: '索提诺比率',
|
||||
nameZh: 'Sortino Ratio',
|
||||
formula: 'Sortino = \\frac{\\bar{r} - r_f}{\\sigma_d}',
|
||||
descriptionEn: 'Return per unit of downside risk (σd=downside deviation)',
|
||||
descriptionZh: '单位下行风险的收益(σd=下行标准差)',
|
||||
descriptionZh: 'Return per unit of downside risk (σd=downside deviation)',
|
||||
},
|
||||
calmar_ratio: {
|
||||
key: 'calmar_ratio',
|
||||
nameEn: 'Calmar Ratio',
|
||||
nameZh: '卡玛比率',
|
||||
nameZh: 'Calmar Ratio',
|
||||
formula: 'Calmar = \\frac{R_{ann}}{|MDD|}',
|
||||
descriptionEn: 'Annualized return divided by maximum drawdown',
|
||||
descriptionZh: '年化收益率与最大回撤的比值',
|
||||
descriptionZh: 'Annualized return divided by maximum drawdown',
|
||||
},
|
||||
win_rate: {
|
||||
key: 'win_rate',
|
||||
nameEn: 'Win Rate',
|
||||
nameZh: '胜率',
|
||||
nameZh: 'Win Rate',
|
||||
formula: 'WinRate = \\frac{N_{win}}{N_{total}} \\times 100\\%',
|
||||
descriptionEn: 'Percentage of profitable trades',
|
||||
descriptionZh: '盈利交易占总交易数的百分比',
|
||||
descriptionZh: 'Percentage of profitable trades',
|
||||
},
|
||||
profit_factor: {
|
||||
key: 'profit_factor',
|
||||
nameEn: 'Profit Factor',
|
||||
nameZh: '盈亏比',
|
||||
nameZh: 'Profit Factor',
|
||||
formula: 'PF = \\frac{\\sum Profits}{|\\sum Losses|}',
|
||||
descriptionEn: 'Ratio of gross profit to gross loss',
|
||||
descriptionZh: '总盈利与总亏损的比值',
|
||||
descriptionZh: 'Ratio of gross profit to gross loss',
|
||||
},
|
||||
volatility: {
|
||||
key: 'volatility',
|
||||
nameEn: 'Volatility',
|
||||
nameZh: '波动率',
|
||||
nameZh: 'Volatility',
|
||||
formula: '\\sigma = \\sqrt{\\frac{1}{n}\\sum_{i=1}^{n}(r_i - \\bar{r})^2}',
|
||||
descriptionEn: 'Standard deviation of returns',
|
||||
descriptionZh: '收益率的标准差',
|
||||
descriptionZh: 'Standard deviation of returns',
|
||||
},
|
||||
var_95: {
|
||||
key: 'var_95',
|
||||
nameEn: 'VaR (95%)',
|
||||
nameZh: '风险价值',
|
||||
nameZh: 'VaR (95%)',
|
||||
formula: 'P(R < VaR_{95\\%}) = 5\\%',
|
||||
descriptionEn: '95% confidence level maximum expected loss',
|
||||
descriptionZh: '95%置信水平下的最大预期损失',
|
||||
descriptionZh: '95% confidence level maximum expected loss',
|
||||
},
|
||||
alpha: {
|
||||
key: 'alpha',
|
||||
nameEn: 'Alpha',
|
||||
nameZh: '超额收益',
|
||||
nameZh: 'Alpha',
|
||||
formula: '\\alpha = R_{portfolio} - R_{benchmark}',
|
||||
descriptionEn: 'Excess return over benchmark',
|
||||
descriptionZh: '相对于基准的超额收益',
|
||||
descriptionZh: 'Excess return over benchmark',
|
||||
},
|
||||
beta: {
|
||||
key: 'beta',
|
||||
nameEn: 'Beta',
|
||||
nameZh: '贝塔系数',
|
||||
nameZh: 'Beta',
|
||||
formula: '\\beta = \\frac{Cov(R_p, R_m)}{Var(R_m)}',
|
||||
descriptionEn: 'Portfolio sensitivity to market movements',
|
||||
descriptionZh: '投资组合对市场波动的敏感度',
|
||||
descriptionZh: 'Portfolio sensitivity to market movements',
|
||||
},
|
||||
information_ratio: {
|
||||
key: 'information_ratio',
|
||||
nameEn: 'Information Ratio',
|
||||
nameZh: '信息比率',
|
||||
nameZh: 'Information Ratio',
|
||||
formula: 'IR = \\frac{\\alpha}{\\sigma_{tracking}}',
|
||||
descriptionEn: 'Alpha per unit of tracking error',
|
||||
descriptionZh: '单位跟踪误差的超额收益',
|
||||
descriptionZh: 'Alpha per unit of tracking error',
|
||||
},
|
||||
avg_trade_pnl: {
|
||||
key: 'avg_trade_pnl',
|
||||
nameEn: 'Avg Trade PnL',
|
||||
nameZh: '平均盈亏',
|
||||
nameZh: 'Avg Trade PnL',
|
||||
formula: '\\bar{PnL} = \\frac{\\sum PnL_i}{N}',
|
||||
descriptionEn: 'Average profit/loss per trade',
|
||||
descriptionZh: '每笔交易的平均盈亏',
|
||||
descriptionZh: 'Average profit/loss per trade',
|
||||
},
|
||||
expectancy: {
|
||||
key: 'expectancy',
|
||||
nameEn: 'Expectancy',
|
||||
nameZh: '期望收益',
|
||||
nameZh: 'Expectancy',
|
||||
formula: 'E = (WinRate \\times \\bar{W}) - (LossRate \\times \\bar{L})',
|
||||
descriptionEn: 'Expected return per trade',
|
||||
descriptionZh: '每笔交易的期望收益',
|
||||
descriptionZh: 'Expected return per trade',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -259,11 +259,11 @@ export function MetricTooltip({
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(145deg, #1E2329 0%, #2B3139 100%)',
|
||||
border: '1px solid #3B4149',
|
||||
background: '#F7F4EC',
|
||||
border: '1px solid rgba(26,24,19,0.14)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.8)',
|
||||
boxShadow: '0 25px 50px -12px rgba(26, 24, 19, 0.18)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
@@ -273,27 +273,27 @@ export function MetricTooltip({
|
||||
gap: '8px',
|
||||
marginBottom: '12px',
|
||||
paddingBottom: '8px',
|
||||
borderBottom: '1px solid #3B4149'
|
||||
borderBottom: '1px solid rgba(26,24,19,0.14)'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: '#F0B90B'
|
||||
background: '#E0483B'
|
||||
}} />
|
||||
<span style={{ fontWeight: 'bold', fontSize: '14px', color: '#EAECEF' }}>
|
||||
<span style={{ fontWeight: 'bold', fontSize: '14px', color: '#1A1813' }}>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Formula */}
|
||||
<div style={{
|
||||
background: 'rgba(0,0,0,0.3)',
|
||||
background: '#E8E2D5',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
marginBottom: '12px'
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', color: '#848E9C', marginBottom: '8px' }}>
|
||||
<div style={{ fontSize: '12px', color: '#8A8478', marginBottom: '8px' }}>
|
||||
{formulaLabel}
|
||||
</div>
|
||||
<div style={{
|
||||
@@ -301,7 +301,7 @@ export function MetricTooltip({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '8px 4px',
|
||||
color: '#EAECEF',
|
||||
color: '#1A1813',
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
maxWidth: '100%',
|
||||
@@ -312,7 +312,7 @@ export function MetricTooltip({
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p style={{ fontSize: '12px', lineHeight: '1.5', color: '#B7BDC6', margin: 0 }}>
|
||||
<p style={{ fontSize: '12px', lineHeight: '1.5', color: '#8A8478', margin: 0 }}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
@@ -333,8 +333,8 @@ export function MetricTooltip({
|
||||
}
|
||||
setShow(!show)
|
||||
}}
|
||||
className={`p-0.5 rounded-full transition-colors hover:bg-white/10 ${className}`}
|
||||
style={{ color: '#848E9C' }}
|
||||
className={`p-0.5 rounded-full transition-colors hover:bg-[rgba(26,24,19,0.06)] ${className}`}
|
||||
style={{ color: '#8A8478' }}
|
||||
aria-label={`Info about ${name}`}
|
||||
>
|
||||
<HelpCircle size={size} />
|
||||
|
||||
@@ -17,9 +17,9 @@ const MODEL_COLORS: Record<string, string> = {
|
||||
claw402: '#7C3AED',
|
||||
}
|
||||
|
||||
// 获取AI模型图标的函数
|
||||
// Returns the icon for an AI model
|
||||
export const getModelIcon = (modelType: string, props: IconProps = {}) => {
|
||||
// 支持完整ID或类型名
|
||||
// Supports full ID or type name
|
||||
const type = modelType.includes('_') ? modelType.split('_').pop() : modelType
|
||||
|
||||
let iconPath: string | null = null
|
||||
@@ -67,8 +67,8 @@ export const getModelIcon = (modelType: string, props: IconProps = {}) => {
|
||||
)
|
||||
}
|
||||
|
||||
// 获取模型颜色(用于没有图标时的fallback)
|
||||
// Returns the model color (fallback for when there is no icon)
|
||||
export const getModelColor = (modelType: string): string => {
|
||||
const type = modelType.includes('_') ? modelType.split('_').pop() : modelType
|
||||
return MODEL_COLORS[type || ''] || '#60a5fa'
|
||||
return MODEL_COLORS[type || ''] || '#E0483B'
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ export function SiteFooter({ language }: SiteFooterProps) {
|
||||
return (
|
||||
<footer
|
||||
className="mt-16"
|
||||
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
|
||||
style={{ borderTop: '1px solid rgba(26,24,19,0.14)', background: '#F7F4EC' }}
|
||||
>
|
||||
<div
|
||||
className="max-w-[1920px] mx-auto px-6 py-6 text-center text-sm"
|
||||
style={{ color: '#5E6673' }}
|
||||
style={{ color: '#8A8478' }}
|
||||
>
|
||||
<p>{t('footerTitle', language)}</p>
|
||||
<p className="mt-1">{t('footerWarning', language)}</p>
|
||||
@@ -24,19 +24,19 @@ export function SiteFooter({ language }: SiteFooterProps) {
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#848E9C',
|
||||
border: '1px solid #2B3139',
|
||||
background: '#F1ECE2',
|
||||
color: '#8A8478',
|
||||
border: '1px solid rgba(26,24,19,0.14)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139'
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
e.currentTarget.style.borderColor = '#F0B90B'
|
||||
e.currentTarget.style.background = '#E8E2D5'
|
||||
e.currentTarget.style.color = '#1A1813'
|
||||
e.currentTarget.style.borderColor = '#E0483B'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1E2329'
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.borderColor = '#2B3139'
|
||||
e.currentTarget.style.background = '#F1ECE2'
|
||||
e.currentTarget.style.color = '#8A8478'
|
||||
e.currentTarget.style.borderColor = 'rgba(26,24,19,0.14)'
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
|
||||
@@ -50,19 +50,19 @@ export function SiteFooter({ language }: SiteFooterProps) {
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#848E9C',
|
||||
border: '1px solid #2B3139',
|
||||
background: '#F1ECE2',
|
||||
color: '#8A8478',
|
||||
border: '1px solid rgba(26,24,19,0.14)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139'
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
e.currentTarget.style.background = '#E8E2D5'
|
||||
e.currentTarget.style.color = '#1A1813'
|
||||
e.currentTarget.style.borderColor = '#1DA1F2'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1E2329'
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.borderColor = '#2B3139'
|
||||
e.currentTarget.style.background = '#F1ECE2'
|
||||
e.currentTarget.style.color = '#8A8478'
|
||||
e.currentTarget.style.borderColor = 'rgba(26,24,19,0.14)'
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
@@ -76,19 +76,19 @@ export function SiteFooter({ language }: SiteFooterProps) {
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-2 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#848E9C',
|
||||
border: '1px solid #2B3139',
|
||||
background: '#F1ECE2',
|
||||
color: '#8A8478',
|
||||
border: '1px solid rgba(26,24,19,0.14)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#2B3139'
|
||||
e.currentTarget.style.color = '#EAECEF'
|
||||
e.currentTarget.style.background = '#E8E2D5'
|
||||
e.currentTarget.style.color = '#1A1813'
|
||||
e.currentTarget.style.borderColor = '#0088cc'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#1E2329'
|
||||
e.currentTarget.style.color = '#848E9C'
|
||||
e.currentTarget.style.borderColor = '#2B3139'
|
||||
e.currentTarget.style.background = '#F1ECE2'
|
||||
e.currentTarget.style.color = '#8A8478'
|
||||
e.currentTarget.style.borderColor = 'rgba(26,24,19,0.14)'
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
|
||||
@@ -73,15 +73,15 @@ export function WebCryptoEnvironmentCheck({
|
||||
|
||||
const isCompact = variant === 'compact'
|
||||
const containerClass = isCompact
|
||||
? 'p-3 rounded border border-gray-700 bg-gray-900 space-y-3'
|
||||
: 'p-4 rounded border border-[#2B3139] bg-[#0B0E11] space-y-4'
|
||||
? 'p-3 rounded border border-[rgba(26,24,19,0.14)] bg-nofx-bg-lighter space-y-3'
|
||||
: 'p-4 rounded border border-[rgba(26,24,19,0.14)] bg-nofx-bg-lighter space-y-4'
|
||||
|
||||
const descriptionColor = isCompact ? '#CBD5F5' : '#A1AEC8'
|
||||
const descriptionColor = isCompact ? '#8A8478' : '#8A8478'
|
||||
const showInfo = status !== 'idle'
|
||||
|
||||
const statusRendererMap: Record<WebCryptoCheckStatus, () => ReactNode> = {
|
||||
secure: () => (
|
||||
<div className="flex items-start gap-2 text-green-400 text-xs">
|
||||
<div className="flex items-start gap-2 text-[#2E8B57] text-xs">
|
||||
<ShieldCheck className="w-4 h-4 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-semibold">
|
||||
@@ -111,7 +111,7 @@ export function WebCryptoEnvironmentCheck({
|
||||
</div>
|
||||
),
|
||||
unsupported: () => (
|
||||
<div className="text-xs" style={{ color: '#F87171' }}>
|
||||
<div className="text-xs" style={{ color: '#D6433A' }}>
|
||||
<div className="flex items-start gap-2 mb-1">
|
||||
<ShieldAlert className="w-4 h-4 flex-shrink-0" />
|
||||
<div className="font-semibold">
|
||||
@@ -122,7 +122,7 @@ export function WebCryptoEnvironmentCheck({
|
||||
</div>
|
||||
),
|
||||
disabled: () => (
|
||||
<div className="flex items-start gap-2 text-gray-400 text-xs">
|
||||
<div className="flex items-start gap-2 text-nofx-text-muted text-xs">
|
||||
<ShieldMinus className="w-4 h-4 flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-semibold">
|
||||
@@ -135,7 +135,7 @@ export function WebCryptoEnvironmentCheck({
|
||||
checking: () => (
|
||||
<div
|
||||
className="flex items-center gap-2 text-xs"
|
||||
style={{ color: '#EAECEF' }}
|
||||
style={{ color: '#1A1813' }}
|
||||
>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>{t('environmentCheck.checking', language)}</span>
|
||||
|
||||
@@ -19,11 +19,9 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-nofx-bg-deeper text-white font-mono relative overflow-hidden flex items-center justify-center px-4">
|
||||
{/* Background Grid & Scanlines */}
|
||||
<div className="fixed inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] pointer-events-none"></div>
|
||||
<div className="fixed inset-0 bg-gradient-to-t from-black via-transparent to-transparent pointer-events-none"></div>
|
||||
<div className="fixed inset-0 pointer-events-none opacity-[0.03] bg-[linear-gradient(transparent_50%,rgba(0,0,0,0.5)_50%)] bg-[length:100%_4px]"></div>
|
||||
<div className="min-h-screen bg-nofx-bg-deeper text-nofx-text font-mono relative overflow-hidden flex items-center justify-center px-4">
|
||||
{/* Background Grid */}
|
||||
<div className="fixed inset-0 bg-[linear-gradient(to_right,rgba(26,24,19,0.04)_1px,transparent_1px),linear-gradient(to_bottom,rgba(26,24,19,0.04)_1px,transparent_1px)] bg-[size:24px_24px] pointer-events-none"></div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
@@ -31,15 +29,15 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
|
||||
transition={{ duration: 0.5 }}
|
||||
className="max-w-lg w-full relative z-10"
|
||||
>
|
||||
<div className="bg-zinc-900/40 backdrop-blur-md border border-red-500/30 rounded-lg overflow-hidden relative group">
|
||||
<div className="bg-nofx-bg-lighter border border-[#D6433A]/30 rounded-lg overflow-hidden relative group">
|
||||
{/* Top Bar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-red-900/20 border-b border-red-500/30">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-[#D6433A]/10 border-b border-[#D6433A]/30">
|
||||
<div className="flex gap-1.5 opacity-50">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-red-500"></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-zinc-600"></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-zinc-600"></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-[#D6433A]"></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-[rgba(26,24,19,0.25)]"></div>
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-[rgba(26,24,19,0.25)]"></div>
|
||||
</div>
|
||||
<div className="text-[10px] text-red-400 font-mono tracking-widest animate-pulse">
|
||||
<div className="text-[10px] text-[#D6433A] font-mono tracking-widest animate-pulse">
|
||||
ACCESS_DENIED // ERROR_403
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,22 +45,21 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
|
||||
<div className="p-8 text-center">
|
||||
{/* Icon */}
|
||||
<div className="relative mx-auto mb-8 w-20 h-20 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-red-500/20 rounded-full animate-ping opacity-50"></div>
|
||||
<div className="relative z-10 p-4 border-2 border-red-500/50 rounded-full bg-black/50">
|
||||
<ShieldAlert className="w-8 h-8 text-red-500" />
|
||||
<div className="relative z-10 p-4 border-2 border-[#D6433A]/50 rounded-full bg-nofx-bg-deeper">
|
||||
<ShieldAlert className="w-8 h-8 text-[#D6433A]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-2xl font-bold mb-2 tracking-widest text-white uppercase glitch-text">
|
||||
<span className="text-red-500">RESTRICTED</span> ACCESS
|
||||
<h1 className="text-2xl font-bold mb-2 tracking-widest text-nofx-text uppercase">
|
||||
<span className="text-[#D6433A]">RESTRICTED</span> ACCESS
|
||||
</h1>
|
||||
|
||||
<div className="h-[1px] w-full bg-gradient-to-r from-transparent via-red-900/50 to-transparent my-4"></div>
|
||||
<div className="h-[1px] w-full bg-gradient-to-r from-transparent via-[#D6433A]/40 to-transparent my-4"></div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-xs text-zinc-400 mb-8 leading-relaxed font-mono px-4">
|
||||
<span className="text-red-400">[SYSTEM_MESSAGE]:</span> YOUR
|
||||
<p className="text-xs text-nofx-text-muted mb-8 leading-relaxed font-mono px-4">
|
||||
<span className="text-[#D6433A]">[SYSTEM_MESSAGE]:</span> YOUR
|
||||
IDENTIFIER IS NOT ON THE ACTIVE WHITELIST.
|
||||
<br />
|
||||
<br />
|
||||
@@ -72,14 +69,14 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
|
||||
</p>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-red-950/20 border border-red-900/30 p-4 rounded mb-8 text-left">
|
||||
<div className="bg-[#D6433A]/8 border border-[#D6433A]/25 p-4 rounded mb-8 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lock className="w-4 h-4 text-red-500 mt-0.5" />
|
||||
<Lock className="w-4 h-4 text-[#D6433A] mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-red-400 uppercase mb-1">
|
||||
<h3 className="text-xs font-bold text-[#D6433A] uppercase mb-1">
|
||||
Authorization Protocol
|
||||
</h3>
|
||||
<p className="text-[10px] text-zinc-500 leading-tight">
|
||||
<p className="text-[10px] text-nofx-text-muted leading-tight">
|
||||
Access is rolled out in batches. If you believe this is an
|
||||
error, please verify your credentials or contact system
|
||||
administrators.
|
||||
@@ -92,7 +89,7 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleBackToLogin}
|
||||
className="w-full flex items-center justify-center gap-2 py-3 border border-zinc-700 bg-black hover:bg-zinc-900 hover:border-red-500 hover:text-red-500 text-zinc-400 transition-all text-xs font-bold tracking-widest uppercase group"
|
||||
className="w-full flex items-center justify-center gap-2 py-3 border border-[rgba(26,24,19,0.14)] bg-nofx-bg hover:bg-nofx-bg-deeper hover:border-[#D6433A] hover:text-[#D6433A] text-nofx-text-muted transition-all text-xs font-bold tracking-widest uppercase group"
|
||||
>
|
||||
<ArrowLeft className="w-3 h-3 group-hover:-translate-x-1 transition-transform" />
|
||||
RETURN TO LOGIN
|
||||
@@ -103,7 +100,7 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
|
||||
href={OFFICIAL_LINKS.twitter}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 py-2 border border-zinc-800 bg-zinc-900/50 hover:bg-zinc-800 text-zinc-500 hover:text-white transition-colors text-[10px] uppercase"
|
||||
className="flex items-center justify-center gap-2 py-2 border border-[rgba(26,24,19,0.14)] bg-nofx-bg hover:bg-nofx-bg-deeper text-nofx-text-muted hover:text-nofx-text transition-colors text-[10px] uppercase"
|
||||
>
|
||||
<Twitter className="w-3 h-3" />
|
||||
Updates
|
||||
@@ -112,7 +109,7 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
|
||||
href={OFFICIAL_LINKS.telegram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 py-2 border border-zinc-800 bg-zinc-900/50 hover:bg-zinc-800 text-zinc-500 hover:text-white transition-colors text-[10px] uppercase"
|
||||
className="flex items-center justify-center gap-2 py-2 border border-[rgba(26,24,19,0.14)] bg-nofx-bg hover:bg-nofx-bg-deeper text-nofx-text-muted hover:text-nofx-text transition-colors text-[10px] uppercase"
|
||||
>
|
||||
<Send className="w-3 h-3" />
|
||||
Support
|
||||
@@ -122,7 +119,7 @@ export function WhitelistFullPage({ onBack }: WhitelistFullPageProps) {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-black/80 p-2 text-[9px] text-zinc-700 text-center border-t border-zinc-800 font-mono uppercase">
|
||||
<div className="bg-nofx-bg-deeper p-2 text-[9px] text-nofx-text-muted text-center border-t border-[rgba(26,24,19,0.14)] font-mono uppercase">
|
||||
ERR_CODE: WLIST_0x403 // SECURITY_LAYER_ACTIVE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,96 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
import type { FAQCategory } from '../../data/faqData'
|
||||
// RoadmapWidget 移除动态嵌入,按需仅展示外部链接
|
||||
import { Fragment, useEffect, useRef } from 'react'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import type { FAQBlock, FAQCategory } from '../../data/faqData'
|
||||
|
||||
interface FAQContentProps {
|
||||
categories: FAQCategory[]
|
||||
language: Language
|
||||
onActiveItemChange: (itemId: string) => void
|
||||
}
|
||||
|
||||
export function FAQContent({
|
||||
categories,
|
||||
language,
|
||||
onActiveItemChange,
|
||||
}: FAQContentProps) {
|
||||
/** Renders text with inline `code` spans (backtick syntax). */
|
||||
function InlineText({ text }: { text: string }) {
|
||||
const parts = text.split(/(`[^`]+`)/g)
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
part.startsWith('`') && part.endsWith('`') ? (
|
||||
<code
|
||||
key={i}
|
||||
className="rounded bg-nofx-bg-deeper border border-[rgba(26,24,19,0.10)] px-1.5 py-0.5 font-mono text-[0.85em] text-nofx-text break-all"
|
||||
>
|
||||
{part.slice(1, -1)}
|
||||
</code>
|
||||
) : (
|
||||
<Fragment key={i}>{part}</Fragment>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Block({ block }: { block: FAQBlock }) {
|
||||
switch (block.type) {
|
||||
case 'p':
|
||||
return (
|
||||
<p className="text-sm leading-6 text-nofx-text-muted">
|
||||
<InlineText text={block.text} />
|
||||
</p>
|
||||
)
|
||||
case 'list':
|
||||
return (
|
||||
<ul className="space-y-1.5">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 text-sm leading-6 text-nofx-text-muted">
|
||||
<span className="mt-[9px] h-1 w-1 shrink-0 rounded-full bg-nofx-gold" />
|
||||
<span>
|
||||
<InlineText text={item} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
case 'steps':
|
||||
return (
|
||||
<ol className="space-y-1.5">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i} className="flex gap-3 text-sm leading-6 text-nofx-text-muted">
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border border-nofx-gold/30 bg-nofx-gold/10 font-mono text-[11px] font-bold text-nofx-gold">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>
|
||||
<InlineText text={item} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
case 'note':
|
||||
return (
|
||||
<div className="border-l-2 border-nofx-gold bg-nofx-gold/10 px-3 py-2 text-sm leading-6 text-nofx-text">
|
||||
<InlineText text={block.text} />
|
||||
</div>
|
||||
)
|
||||
case 'links':
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{block.links.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded border border-nofx-gold/25 bg-nofx-bg-deeper px-2.5 py-1 font-mono text-xs font-semibold text-nofx-gold hover:border-nofx-gold/50 hover:bg-nofx-gold/10"
|
||||
>
|
||||
{link.label}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function FAQContent({ categories, onActiveItemChange }: FAQContentProps) {
|
||||
const sectionRefs = useRef<Map<string, HTMLElement>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
@@ -22,415 +99,60 @@ export function FAQContent({
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const itemId = entry.target.getAttribute('data-item-id')
|
||||
if (itemId) {
|
||||
onActiveItemChange(itemId)
|
||||
}
|
||||
if (itemId) onActiveItemChange(itemId)
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
rootMargin: '-100px 0px -80% 0px',
|
||||
threshold: 0,
|
||||
}
|
||||
{ rootMargin: '-100px 0px -80% 0px', threshold: 0 }
|
||||
)
|
||||
|
||||
sectionRefs.current.forEach((ref) => {
|
||||
if (ref) observer.observe(ref)
|
||||
})
|
||||
|
||||
sectionRefs.current.forEach((ref) => observer.observe(ref))
|
||||
return () => {
|
||||
sectionRefs.current.forEach((ref) => {
|
||||
if (ref) observer.unobserve(ref)
|
||||
})
|
||||
sectionRefs.current.forEach((ref) => observer.unobserve(ref))
|
||||
}
|
||||
}, [onActiveItemChange])
|
||||
}, [onActiveItemChange, categories])
|
||||
|
||||
const setRef = (itemId: string, element: HTMLElement | null) => {
|
||||
if (element) {
|
||||
sectionRefs.current.set(itemId, element)
|
||||
} else {
|
||||
sectionRefs.current.delete(itemId)
|
||||
}
|
||||
if (element) sectionRefs.current.set(itemId, element)
|
||||
else sectionRefs.current.delete(itemId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<div className="space-y-8">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="nofx-glass p-8 rounded-xl border border-white/5">
|
||||
{/* Category Header */}
|
||||
<div className="flex items-center gap-3 mb-6 pb-3 border-b border-white/10">
|
||||
<category.icon className="w-7 h-7 text-nofx-gold" />
|
||||
<h2 className="text-2xl font-bold text-nofx-text-main">
|
||||
{t(category.titleKey, language)}
|
||||
<div
|
||||
key={category.id}
|
||||
id={category.id}
|
||||
className="overflow-hidden rounded-xl border border-nofx-gold/20 bg-nofx-bg-lighter"
|
||||
>
|
||||
{/* category header — terminal small-caps strip */}
|
||||
<div className="flex items-center gap-2.5 border-b border-nofx-gold/20 bg-nofx-bg px-5 py-3 md:px-6">
|
||||
<category.icon className="h-4 w-4 text-nofx-gold" />
|
||||
<h2 className="font-mono text-xs font-bold uppercase tracking-[0.18em] text-nofx-text">
|
||||
{category.title}
|
||||
</h2>
|
||||
<span className="ml-auto font-mono text-[10px] uppercase tracking-[0.12em] text-nofx-text-muted">
|
||||
{category.items.length} {category.items.length === 1 ? 'entry' : 'entries'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* FAQ Items */}
|
||||
<div className="space-y-8">
|
||||
<div className="divide-y divide-[rgba(26,24,19,0.10)]">
|
||||
{category.items.map((item) => (
|
||||
<section
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
data-item-id={item.id}
|
||||
ref={(el) => setRef(item.id, el)}
|
||||
className="scroll-mt-24"
|
||||
className="scroll-mt-24 px-5 py-5 md:px-6"
|
||||
>
|
||||
{/* Question */}
|
||||
<h3 className="text-xl font-semibold mb-3 text-nofx-text-main">
|
||||
{t(item.questionKey, language)}
|
||||
<h3 className="mb-3 text-[15px] font-semibold leading-6 text-nofx-text">
|
||||
{item.question}
|
||||
</h3>
|
||||
|
||||
{/* Answer */}
|
||||
<div className="prose prose-invert max-w-none text-nofx-text-muted leading-relaxed">
|
||||
{item.id === 'github-projects-tasks' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-base">
|
||||
{language === 'zh' ? '链接:' : 'Links:'}{' '}
|
||||
<a
|
||||
href="https://github.com/orgs/NoFxAiOS/projects/3"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{language === 'zh' ? '路线图' : 'Roadmap'}
|
||||
</a>
|
||||
{' | '}
|
||||
<a
|
||||
href="https://github.com/orgs/NoFxAiOS/projects/5"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{language === 'zh' ? '任务看板' : 'Task Dashboard'}
|
||||
</a>
|
||||
</div>
|
||||
<ol className="list-decimal pl-5 space-y-1 text-base">
|
||||
{language === 'zh' ? (
|
||||
<>
|
||||
<li>
|
||||
打开以上链接,按标签筛选(good first issue / help
|
||||
wanted / frontend / backend)。
|
||||
</li>
|
||||
<li>
|
||||
打开任务,阅读描述与验收标准(Acceptance
|
||||
Criteria)。
|
||||
</li>
|
||||
<li>评论“assign me”或自助分配(若权限允许)。</li>
|
||||
<li>Fork 仓库到你的 GitHub 账户。</li>
|
||||
<li>
|
||||
同步你的 fork 的 <code>dev</code>{' '}
|
||||
分支与上游保持一致:
|
||||
<code className="ml-2">
|
||||
git remote add upstream
|
||||
https://github.com/NoFxAiOS/nofx.git
|
||||
</code>
|
||||
<br />
|
||||
<code>git fetch upstream</code>
|
||||
<br />
|
||||
<code>git checkout dev</code>
|
||||
<br />
|
||||
<code>git rebase upstream/dev</code>
|
||||
<br />
|
||||
<code>git push origin dev</code>
|
||||
</li>
|
||||
<li>
|
||||
从你的 fork 的 <code>dev</code> 建立特性分支:
|
||||
<code className="ml-2">
|
||||
git checkout -b feat/your-topic
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
推送到你的 fork:
|
||||
<code className="ml-2">
|
||||
git push origin feat/your-topic
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
打开 PR:base 选择 <code>NoFxAiOS/nofx:dev</code>{' '}
|
||||
← compare 选择{' '}
|
||||
<code>你的用户名/nofx:feat/your-topic</code>。
|
||||
</li>
|
||||
<li>
|
||||
在 PR 中关联 Issue(示例:
|
||||
<code className="ml-1">Closes #123</code>
|
||||
),选择正确 PR 模板;必要时与{' '}
|
||||
<code>upstream/dev</code>{' '}
|
||||
同步(rebase)后继续推送。
|
||||
</li>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li>
|
||||
Open the links above and filter by labels (good
|
||||
first issue / help wanted / frontend / backend).
|
||||
</li>
|
||||
<li>
|
||||
Open the task and read the Description &
|
||||
Acceptance Criteria.
|
||||
</li>
|
||||
<li>
|
||||
Comment "assign me" or self-assign (if permitted).
|
||||
</li>
|
||||
<li>Fork the repository to your GitHub account.</li>
|
||||
<li>
|
||||
Sync your fork's <code>dev</code> with upstream:
|
||||
<code className="ml-2">
|
||||
git remote add upstream
|
||||
https://github.com/NoFxAiOS/nofx.git
|
||||
</code>
|
||||
<br />
|
||||
<code>git fetch upstream</code>
|
||||
<br />
|
||||
<code>git checkout dev</code>
|
||||
<br />
|
||||
<code>git rebase upstream/dev</code>
|
||||
<br />
|
||||
<code>git push origin dev</code>
|
||||
</li>
|
||||
<li>
|
||||
Create a feature branch from your fork's{' '}
|
||||
<code>dev</code>:
|
||||
<code className="ml-2">
|
||||
git checkout -b feat/your-topic
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Push to your fork:
|
||||
<code className="ml-2">
|
||||
git push origin feat/your-topic
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
Open a PR: base <code>NoFxAiOS/nofx:dev</code> ←
|
||||
compare{' '}
|
||||
<code>your-username/nofx:feat/your-topic</code>.
|
||||
</li>
|
||||
<li>
|
||||
In PR, reference the Issue (e.g.,{' '}
|
||||
<code className="ml-1">Closes #123</code>) and
|
||||
choose the proper PR template; rebase onto{' '}
|
||||
<code>upstream/dev</code> as needed.
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ol>
|
||||
|
||||
<div
|
||||
className="rounded p-3 mt-3"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.08)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.25)',
|
||||
}}
|
||||
>
|
||||
{language === 'zh' ? (
|
||||
<div className="text-sm">
|
||||
<strong style={{ color: '#F0B90B' }}>提示:</strong>{' '}
|
||||
参与贡献将享有激励制度(如
|
||||
Bounty/奖金、荣誉徽章与鸣谢、优先
|
||||
Review/合并与内测资格 等)。 可在任务中优先选择带
|
||||
<a
|
||||
href="https://github.com/NoFxAiOS/nofx/labels/bounty"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
bounty 标签
|
||||
</a>
|
||||
的事项,或完成后提交
|
||||
<a
|
||||
href="https://github.com/NoFxAiOS/nofx/blob/dev/.github/ISSUE_TEMPLATE/bounty_claim.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
Bounty Claim
|
||||
</a>
|
||||
申请。
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm">
|
||||
<strong style={{ color: '#F0B90B' }}>Note:</strong>{' '}
|
||||
Contribution incentives are available (e.g., cash
|
||||
bounties, badges & shout-outs, priority
|
||||
review/merge, beta access). Prefer tasks with
|
||||
<a
|
||||
href="https://github.com/NoFxAiOS/nofx/labels/bounty"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
bounty label
|
||||
</a>
|
||||
, or file a
|
||||
<a
|
||||
href="https://github.com/NoFxAiOS/nofx/blob/dev/.github/ISSUE_TEMPLATE/bounty_claim.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
Bounty Claim
|
||||
</a>
|
||||
after completion.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : item.id === 'contribute-pr-guidelines' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-base">
|
||||
{language === 'zh' ? '参考文档:' : 'References:'}{' '}
|
||||
<a
|
||||
href="https://github.com/NoFxAiOS/nofx/blob/dev/CONTRIBUTING.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-nofx-gold hover:underline"
|
||||
>
|
||||
CONTRIBUTING.md
|
||||
</a>
|
||||
{' | '}
|
||||
<a
|
||||
href="https://github.com/NoFxAiOS/nofx/blob/dev/.github/PR_TITLE_GUIDE.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-nofx-gold hover:underline"
|
||||
>
|
||||
PR_TITLE_GUIDE.md
|
||||
</a>
|
||||
</div>
|
||||
<ol className="list-decimal pl-5 space-y-1 text-base">
|
||||
{language === 'zh' ? (
|
||||
<>
|
||||
<li>
|
||||
Fork 仓库后,从你的 fork 的 <code>dev</code>{' '}
|
||||
分支创建特性分支;避免直接向上游 <code>main</code>{' '}
|
||||
提交。
|
||||
</li>
|
||||
<li>
|
||||
分支命名:feat/…、fix/…、docs/…;提交信息遵循
|
||||
Conventional Commits。
|
||||
</li>
|
||||
<li>
|
||||
提交前运行检查:
|
||||
<code className="ml-2">
|
||||
npm --prefix web run lint && npm --prefix web
|
||||
run build
|
||||
</code>
|
||||
</li>
|
||||
<li>涉及 UI 变更请附截图或短视频。</li>
|
||||
<li>
|
||||
选择正确的 PR
|
||||
模板(frontend/backend/docs/general)。
|
||||
</li>
|
||||
<li>
|
||||
在 PR 中关联 Issue(示例:
|
||||
<code className="ml-1">Closes #123</code>),PR
|
||||
目标选择 <code>NoFxAiOS/nofx:dev</code>。
|
||||
</li>
|
||||
<li>
|
||||
保持与 <code>upstream/dev</code>{' '}
|
||||
同步(rebase),确保 CI 通过;尽量保持 PR
|
||||
小而聚焦。
|
||||
</li>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li>
|
||||
After forking, branch from your fork's{' '}
|
||||
<code>dev</code>; avoid direct commits to upstream{' '}
|
||||
<code>main</code>.
|
||||
</li>
|
||||
<li>
|
||||
Branch naming: feat/…, fix/…, docs/…; commit
|
||||
messages follow Conventional Commits.
|
||||
</li>
|
||||
<li>
|
||||
Run checks before PR:
|
||||
<code className="ml-2">
|
||||
npm --prefix web run lint && npm --prefix web
|
||||
run build
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
For UI changes, attach screenshots or a short
|
||||
video.
|
||||
</li>
|
||||
<li>
|
||||
Choose the proper PR template
|
||||
(frontend/backend/docs/general).
|
||||
</li>
|
||||
<li>
|
||||
Link the Issue in PR (e.g.,{' '}
|
||||
<code className="ml-1">Closes #123</code>) and
|
||||
target <code>NoFxAiOS/nofx:dev</code>.
|
||||
</li>
|
||||
<li>
|
||||
Keep rebasing onto <code>upstream/dev</code>,
|
||||
ensure CI passes; prefer small and focused PRs.
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ol>
|
||||
|
||||
<div className="rounded p-3 mt-3 bg-nofx-gold/10 border border-nofx-gold/25">
|
||||
{language === 'zh' ? (
|
||||
<div className="text-sm">
|
||||
<strong className="text-nofx-gold">Note:</strong>{' '}
|
||||
我们为高质量贡献提供激励(Bounty/奖金、荣誉徽章与鸣谢、优先
|
||||
Review/合并与内测资格 等)。 详情可关注带
|
||||
<a
|
||||
href="https://github.com/NoFxAiOS/nofx/labels/bounty"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
bounty 标签
|
||||
</a>
|
||||
的任务,或使用
|
||||
<a
|
||||
href="https://github.com/NoFxAiOS/nofx/blob/dev/.github/ISSUE_TEMPLATE/bounty_claim.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
Bounty Claim 模板
|
||||
</a>
|
||||
提交申请。
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm">
|
||||
<strong style={{ color: '#F0B90B' }}>Note:</strong>{' '}
|
||||
We offer contribution incentives (bounties, badges,
|
||||
shout-outs, priority review/merge, beta access).
|
||||
Look for tasks with
|
||||
<a
|
||||
href="https://github.com/NoFxAiOS/nofx/labels/bounty"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
bounty label
|
||||
</a>
|
||||
, or submit a
|
||||
<a
|
||||
href="https://github.com/NoFxAiOS/nofx/blob/dev/.github/ISSUE_TEMPLATE/bounty_claim.md"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
Bounty Claim
|
||||
</a>
|
||||
when ready.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-base">{t(item.answerKey, language)}</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{item.blocks.map((block, i) => (
|
||||
<Block key={i} block={block} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mt-6 h-px bg-white/5" />
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,160 +1,121 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
import { DeepVoidBackground } from '../common/DeepVoidBackground'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
import { FAQSearchBar } from './FAQSearchBar'
|
||||
import { FAQSidebar } from './FAQSidebar'
|
||||
import { FAQContent } from './FAQContent'
|
||||
import { faqCategories } from '../../data/faqData'
|
||||
import { faqCategories, faqItemSearchText } from '../../data/faqData'
|
||||
import type { FAQCategory } from '../../data/faqData'
|
||||
|
||||
interface FAQLayoutProps {
|
||||
language: Language
|
||||
}
|
||||
|
||||
export function FAQLayout({ language }: FAQLayoutProps) {
|
||||
export function FAQLayout() {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [activeItemId, setActiveItemId] = useState<string | null>(null)
|
||||
|
||||
// Filter categories based on search term
|
||||
const filteredCategories = useMemo(() => {
|
||||
if (!searchTerm.trim()) {
|
||||
return faqCategories
|
||||
}
|
||||
if (!searchTerm.trim()) return faqCategories
|
||||
|
||||
const term = searchTerm.toLowerCase()
|
||||
const filtered: FAQCategory[] = []
|
||||
|
||||
faqCategories.forEach((category) => {
|
||||
const matchingItems = category.items.filter((item) => {
|
||||
const question = t(item.questionKey, language).toLowerCase()
|
||||
const answer = t(item.answerKey, language).toLowerCase()
|
||||
return question.includes(term) || answer.includes(term)
|
||||
})
|
||||
|
||||
const matchingItems = category.items.filter((item) =>
|
||||
faqItemSearchText(item).includes(term)
|
||||
)
|
||||
if (matchingItems.length > 0) {
|
||||
filtered.push({
|
||||
...category,
|
||||
items: matchingItems,
|
||||
})
|
||||
filtered.push({ ...category, items: matchingItems })
|
||||
}
|
||||
})
|
||||
|
||||
return filtered
|
||||
}, [searchTerm, language])
|
||||
}, [searchTerm])
|
||||
|
||||
const totalItems = useMemo(
|
||||
() => faqCategories.reduce((sum, category) => sum + category.items.length, 0),
|
||||
[]
|
||||
)
|
||||
|
||||
const handleItemClick = (_categoryId: string, itemId: string) => {
|
||||
const element = document.getElementById(itemId)
|
||||
if (element) {
|
||||
const offset = 100
|
||||
const elementPosition = element.getBoundingClientRect().top
|
||||
const offsetPosition = elementPosition + window.pageYOffset - offset
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
if (!element) return
|
||||
const offset = 100
|
||||
const top =
|
||||
element.getBoundingClientRect().top + window.pageYOffset - offset
|
||||
window.scrollTo({ top, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
return (
|
||||
<DeepVoidBackground className="py-6 pt-24" disableAnimation>
|
||||
<div className="w-full px-4 md:px-8">
|
||||
{/* Page Header */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center bg-gradient-to-br from-nofx-gold to-[#FCD535] shadow-[0_8px_24px_rgba(240,185,11,0.4)]">
|
||||
<HelpCircle className="w-8 h-8 text-[#0B0E11]" />
|
||||
<DeepVoidBackground className="py-8 pt-24" disableAnimation>
|
||||
<div className="mx-auto w-full max-w-6xl px-4 md:px-8">
|
||||
{/* page header — same strip language as the other terminal pages */}
|
||||
<div className="mb-8 border-b border-nofx-gold/20 pb-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl border border-nofx-gold/30 bg-nofx-bg-lighter text-nofx-gold md:h-14 md:w-14">
|
||||
<HelpCircle className="h-6 w-6 md:h-7 md:w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-mono text-2xl font-bold tracking-tight text-nofx-text md:text-3xl">
|
||||
FAQ
|
||||
</h1>
|
||||
<p className="mt-1 font-mono text-xs uppercase tracking-[0.14em] text-nofx-text-muted">
|
||||
{totalItems} answers · wallets · launch · trading · self-hosting
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full md:w-80">
|
||||
<FAQSearchBar
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold mb-4 text-nofx-text-main">
|
||||
{t('faqTitle', language)}
|
||||
</h1>
|
||||
<p className="text-lg mb-8 text-nofx-text-muted">
|
||||
{t('faqSubtitle', language)}
|
||||
</p>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<FAQSearchBar
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
placeholder={
|
||||
language === 'zh' ? '搜索常见问题...' : 'Search FAQ...'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
{/* content */}
|
||||
<div className="flex gap-8">
|
||||
{/* Sidebar - Hidden on mobile, visible on desktop */}
|
||||
<aside className="hidden lg:block w-64 flex-shrink-0">
|
||||
<aside className="hidden w-64 flex-shrink-0 lg:block">
|
||||
<FAQSidebar
|
||||
categories={filteredCategories}
|
||||
activeItemId={activeItemId}
|
||||
language={language}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Content Area */}
|
||||
<main className="flex-1 min-w-0">
|
||||
<main className="min-w-0 flex-1">
|
||||
{filteredCategories.length > 0 ? (
|
||||
<FAQContent
|
||||
categories={filteredCategories}
|
||||
language={language}
|
||||
onActiveItemChange={setActiveItemId}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-lg" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh'
|
||||
? '没有找到匹配的问题'
|
||||
: 'No matching questions found'}
|
||||
<div className="rounded-xl border border-nofx-gold/20 bg-nofx-bg-lighter py-16 text-center">
|
||||
<p className="font-mono text-sm text-nofx-text-muted">
|
||||
No matching questions for “{searchTerm}”.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="mt-4 px-6 py-2 rounded-lg font-semibold transition-all hover:opacity-90"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
color: '#0B0E11',
|
||||
}}
|
||||
className="mt-4 rounded-lg border border-nofx-gold/30 bg-nofx-gold/10 px-5 py-2 font-mono text-xs font-bold uppercase tracking-[0.12em] text-nofx-gold hover:bg-nofx-gold/20"
|
||||
>
|
||||
{language === 'zh' ? '清除搜索' : 'Clear Search'}
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Contact Section */}
|
||||
<div
|
||||
className="mt-16 p-8 rounded-lg text-center"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(240, 185, 11, 0.1) 0%, rgba(252, 213, 53, 0.05) 100%)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
>
|
||||
<h3 className="text-xl font-bold mb-3" style={{ color: '#EAECEF' }}>
|
||||
{t('faqStillHaveQuestions', language)}
|
||||
{/* still stuck */}
|
||||
<div className="mt-12 rounded-xl border border-nofx-gold/20 bg-nofx-bg-lighter p-6 text-center md:p-8">
|
||||
<h3 className="font-mono text-sm font-bold uppercase tracking-[0.16em] text-nofx-text">
|
||||
Still have questions?
|
||||
</h3>
|
||||
<p className="mb-6" style={{ color: '#848E9C' }}>
|
||||
{t('faqContactUs', language)}
|
||||
<p className="mt-2 text-sm text-nofx-text-muted">
|
||||
Ask in the community or open an issue — both are answered by the
|
||||
people building NOFX.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className="mt-5 flex items-center justify-center gap-3">
|
||||
<a
|
||||
href="https://github.com/NoFxAiOS/nofx"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
color: '#EAECEF',
|
||||
border: '1px solid #2B3139',
|
||||
}}
|
||||
className="rounded-lg border border-[rgba(26,24,19,0.14)] bg-nofx-bg-deeper px-5 py-2.5 font-mono text-xs font-bold uppercase tracking-[0.12em] text-nofx-text hover:border-nofx-gold/40"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
@@ -162,13 +123,9 @@ export function FAQLayout({ language }: FAQLayoutProps) {
|
||||
href="https://t.me/nofx_dev_community"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-6 py-3 rounded-lg font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)',
|
||||
color: '#0B0E11',
|
||||
}}
|
||||
className="rounded-lg bg-nofx-gold px-5 py-2.5 font-mono text-xs font-bold uppercase tracking-[0.12em] text-white hover:bg-nofx-accent"
|
||||
>
|
||||
{t('community', language)}
|
||||
Telegram community
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,12 +21,12 @@ export function FAQSearchBar({
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-12 pr-12 py-3 rounded-lg text-base transition-all focus:outline-none bg-black/40 border border-white/10 text-nofx-text-main placeholder-nofx-text-muted/50 focus:border-nofx-gold/50 focus:ring-1 focus:ring-nofx-gold/20 hover:border-nofx-gold/30 font-mono"
|
||||
className="w-full pl-12 pr-12 py-3 rounded-lg text-base transition-all focus:outline-none bg-nofx-bg-lighter border border-[rgba(26,24,19,0.14)] text-nofx-text placeholder-nofx-text-muted/50 focus:border-nofx-gold/50 focus:ring-1 focus:ring-nofx-gold/20 hover:border-nofx-gold/30 font-mono"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => onSearchChange('')}
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 text-nofx-text-muted hover:text-white transition-colors"
|
||||
className="absolute right-4 top-1/2 transform -translate-y-1/2 text-nofx-text-muted hover:text-nofx-text transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
@@ -1,52 +1,44 @@
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
import type { FAQCategory } from '../../data/faqData'
|
||||
|
||||
interface FAQSidebarProps {
|
||||
categories: FAQCategory[]
|
||||
activeItemId: string | null
|
||||
language: Language
|
||||
onItemClick: (categoryId: string, itemId: string) => void
|
||||
}
|
||||
|
||||
export function FAQSidebar({
|
||||
categories,
|
||||
activeItemId,
|
||||
language,
|
||||
onItemClick,
|
||||
}: FAQSidebarProps) {
|
||||
return (
|
||||
<nav
|
||||
className="sticky top-24 h-[calc(100vh-120px)] overflow-y-auto pr-4"
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: '#2B3139 #1E2329',
|
||||
}}
|
||||
className="sticky top-24 h-[calc(100vh-120px)] overflow-y-auto pr-2"
|
||||
style={{ scrollbarWidth: 'thin', scrollbarColor: '#E8E2D5 transparent' }}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-5">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="nofx-glass p-4 rounded-xl border border-white/5">
|
||||
{/* Category Title */}
|
||||
<div className="flex items-center gap-2 mb-3 px-3">
|
||||
<category.icon className="w-5 h-5 text-nofx-gold" />
|
||||
<h3 className="text-sm font-bold uppercase tracking-wide text-nofx-gold">
|
||||
{t(category.titleKey, language)}
|
||||
<div key={category.id}>
|
||||
<div className="mb-2 flex items-center gap-2 px-2">
|
||||
<category.icon className="h-3.5 w-3.5 text-nofx-gold" />
|
||||
<h3 className="font-mono text-[11px] font-bold uppercase tracking-[0.16em] text-nofx-gold">
|
||||
{category.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Category Items */}
|
||||
<ul className="space-y-1">
|
||||
<ul className="space-y-0.5 border-l border-[rgba(26,24,19,0.12)]">
|
||||
{category.items.map((item) => {
|
||||
const isActive = activeItemId === item.id
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
onClick={() => onItemClick(category.id, item.id)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-all border-l-[3px] ${isActive
|
||||
? 'bg-nofx-gold/10 text-nofx-gold border-nofx-gold pl-[9px]'
|
||||
: 'bg-transparent text-nofx-text-muted border-transparent pl-3 hover:bg-nofx-gold/5 hover:text-nofx-text-main'
|
||||
}`}
|
||||
className={`-ml-px w-full border-l-2 py-1.5 pl-3 pr-2 text-left text-[13px] leading-5 transition-colors ${
|
||||
isActive
|
||||
? 'border-nofx-gold bg-nofx-gold/5 font-medium text-nofx-text'
|
||||
: 'border-transparent text-nofx-text-muted hover:border-nofx-gold/40 hover:text-nofx-text'
|
||||
}`}
|
||||
>
|
||||
{t(item.questionKey, language)}
|
||||
{item.question}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Terminal, Shield, Cpu, BarChart3 } from 'lucide-react'
|
||||
import { t, Language } from '../../i18n/translations'
|
||||
|
||||
interface AboutSectionProps {
|
||||
language: Language
|
||||
}
|
||||
|
||||
export default function AboutSection({ language }: AboutSectionProps) {
|
||||
const features = [
|
||||
{
|
||||
icon: Shield,
|
||||
title: language === 'zh' ? '完全自主控制' : 'Full Control',
|
||||
desc: language === 'zh' ? '自托管,数据安全' : 'Self-hosted, data secure',
|
||||
},
|
||||
{
|
||||
icon: Cpu,
|
||||
title: language === 'zh' ? '多 AI 支持' : 'Multi-AI Support',
|
||||
desc: language === 'zh' ? 'DeepSeek, GPT, Claude...' : 'DeepSeek, GPT, Claude...',
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: language === 'zh' ? '实时监控' : 'Real-time Monitor',
|
||||
desc: language === 'zh' ? '可视化交易看板' : 'Visual trading dashboard',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="py-24 relative overflow-hidden" style={{ background: '#0B0E11' }}>
|
||||
{/* Background Decoration */}
|
||||
<div
|
||||
className="absolute top-0 right-0 w-96 h-96 rounded-full blur-3xl opacity-30"
|
||||
style={{ background: 'radial-gradient(circle, rgba(240, 185, 11, 0.1) 0%, transparent 70%)' }}
|
||||
/>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="grid lg:grid-cols-2 gap-16 items-center">
|
||||
{/* Left Content */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<motion.div
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full mb-6"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
>
|
||||
<Terminal className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#F0B90B' }}>
|
||||
{t('aboutNofx', language)}
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
<h2 className="text-4xl lg:text-5xl font-bold mb-6" style={{ color: '#EAECEF' }}>
|
||||
{t('whatIsNofx', language)}
|
||||
</h2>
|
||||
|
||||
<p className="text-lg mb-8 leading-relaxed" style={{ color: '#848E9C' }}>
|
||||
{t('nofxNotAnotherBot', language)} {t('nofxDescription1', language)}
|
||||
</p>
|
||||
|
||||
{/* Feature Pills */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={feature.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.03)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{ background: 'rgba(240, 185, 11, 0.1)' }}
|
||||
>
|
||||
<feature.icon className="w-5 h-5" style={{ color: '#F0B90B' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
|
||||
{feature.title}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#5E6673' }}>
|
||||
{feature.desc}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Right - Terminal */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
<div
|
||||
className="rounded-2xl overflow-hidden"
|
||||
style={{
|
||||
background: '#0D1117',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
>
|
||||
{/* Terminal Header */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-3"
|
||||
style={{ background: 'rgba(255, 255, 255, 0.03)', borderBottom: '1px solid rgba(255, 255, 255, 0.06)' }}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ background: '#FF5F56' }} />
|
||||
<div className="w-3 h-3 rounded-full" style={{ background: '#FFBD2E' }} />
|
||||
<div className="w-3 h-3 rounded-full" style={{ background: '#27C93F' }} />
|
||||
</div>
|
||||
<span className="text-xs ml-2" style={{ color: '#5E6673' }}>terminal</span>
|
||||
</div>
|
||||
|
||||
{/* Terminal Content */}
|
||||
<div className="p-6 font-mono text-sm space-y-2">
|
||||
<div style={{ color: '#5E6673' }}>$ git clone https://github.com/NoFxAiOS/nofx.git</div>
|
||||
<div style={{ color: '#5E6673' }}>$ cd nofx && chmod +x start.sh</div>
|
||||
<div style={{ color: '#5E6673' }}>$ ./start.sh start --build</div>
|
||||
<div className="pt-2" style={{ color: '#F0B90B' }}>
|
||||
✓ {t('startupMessages1', language)}
|
||||
</div>
|
||||
<div style={{ color: '#0ECB81' }}>
|
||||
✓ {t('startupMessages2', language)}
|
||||
</div>
|
||||
<div style={{ color: '#0ECB81' }}>
|
||||
✓ {t('startupMessages3', language)}
|
||||
</div>
|
||||
<motion.div
|
||||
className="flex items-center gap-2 pt-2"
|
||||
animate={{ opacity: [1, 0.5, 1] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity }}
|
||||
>
|
||||
<span style={{ color: '#F0B90B' }}>▸</span>
|
||||
<span style={{ color: '#EAECEF' }}>_</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useRef } from 'react'
|
||||
import { motion, useInView } from 'framer-motion'
|
||||
|
||||
export default function AnimatedSection({
|
||||
children,
|
||||
id,
|
||||
backgroundColor = 'var(--brand-black)',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
id?: string
|
||||
backgroundColor?: string
|
||||
}) {
|
||||
const ref = useRef(null)
|
||||
const isInView = useInView(ref, { once: true, margin: '-100px' })
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
id={id}
|
||||
ref={ref}
|
||||
className="py-20 px-4"
|
||||
style={{ background: backgroundColor }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={isInView ? { opacity: 1 } : { opacity: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
{children}
|
||||
</motion.section>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user