Compare commits
28 Commits
cleanup-be
...
781b88f803
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
781b88f803 | ||
|
|
7c4adff4e4 | ||
|
|
b7b0fb9bb2 | ||
|
|
25d31ff75a | ||
|
|
6e87fc8749 | ||
|
|
da72b5de04 | ||
|
|
e068c5f2e6 | ||
|
|
65e6a8efed | ||
|
|
d4d2883af6 | ||
|
|
797e80b56a | ||
|
|
f7cf9ec63b | ||
|
|
344a79a753 | ||
|
|
fe4d9bc954 | ||
|
|
27f78748cf | ||
|
|
715fa8bd11 | ||
|
|
e8a9b68fa7 | ||
|
|
19f5b7ab14 | ||
|
|
a72ddd8f0e | ||
|
|
9bf83260c4 | ||
|
|
a07bf9f4b2 | ||
|
|
1acb5e7210 | ||
|
|
6a04d3469f | ||
|
|
9808d52d3f | ||
|
|
dde25ad2c1 | ||
|
|
eeb90ad455 | ||
|
|
8f90339d8d | ||
|
|
17b0806ff3 | ||
|
|
14d5de2c64 |
22
.env
22
.env
@@ -61,7 +61,7 @@ PYTH_HERMES_URL=https://hermes.pyth.network
|
||||
# Position sizing
|
||||
# Base position size in USD (default: 50 for safe testing)
|
||||
# Example: 50 with 10x leverage = $500 notional position
|
||||
MAX_POSITION_SIZE_USD=80
|
||||
MAX_POSITION_SIZE_USD=54
|
||||
|
||||
# Leverage multiplier (1-20, default: 10)
|
||||
# Higher leverage = bigger gains AND bigger losses
|
||||
@@ -70,7 +70,7 @@ LEVERAGE=10
|
||||
# Risk parameters (as percentages)
|
||||
# Stop Loss: Close 100% of position when price drops this much
|
||||
# Example: -1.5% on 10x = -15% account loss
|
||||
STOP_LOSS_PERCENT=-1.5
|
||||
STOP_LOSS_PERCENT=-1.1
|
||||
|
||||
# ================================
|
||||
# DUAL STOP SYSTEM (Advanced)
|
||||
@@ -93,7 +93,7 @@ HARD_STOP_PERCENT=-2.5
|
||||
|
||||
# Take Profit 1: Close 50% of position at this profit level
|
||||
# Example: +0.7% on 10x = +7% account gain
|
||||
TAKE_PROFIT_1_PERCENT=0.7
|
||||
TAKE_PROFIT_1_PERCENT=0.4
|
||||
|
||||
# Take Profit 1 Size: What % of position to close at TP1
|
||||
# Example: 50 = close 50% of position
|
||||
@@ -101,7 +101,7 @@ TAKE_PROFIT_1_SIZE_PERCENT=75
|
||||
|
||||
# Take Profit 2: Close remaining 50% at this profit level
|
||||
# Example: +1.5% on 10x = +15% account gain
|
||||
TAKE_PROFIT_2_PERCENT=1.1
|
||||
TAKE_PROFIT_2_PERCENT=0.7
|
||||
|
||||
# Take Profit 2 Size: What % of remaining position to close at TP2
|
||||
# Example: 100 = close all remaining position
|
||||
@@ -129,9 +129,9 @@ MAX_DAILY_DRAWDOWN=-50
|
||||
# Maximum number of trades allowed per hour (prevents overtrading)
|
||||
MAX_TRADES_PER_HOUR=20
|
||||
|
||||
# Minimum time between trades in seconds (cooldown period)
|
||||
# Example: 600 = 10 minutes between trades
|
||||
MIN_TIME_BETWEEN_TRADES=0
|
||||
# Minimum time between trades in minutes (cooldown period)
|
||||
# Example: 10 = 10 minutes between trades
|
||||
MIN_TIME_BETWEEN_TRADES=21
|
||||
|
||||
# DEX execution settings
|
||||
# Maximum acceptable slippage on market orders (percentage)
|
||||
@@ -153,7 +153,7 @@ CONFIRMATION_TIMEOUT_MS=30000
|
||||
# n8n instance URL (for workflow automation)
|
||||
# Get from: https://n8n.io (cloud) or self-hosted
|
||||
# Example: https://your-username.app.n8n.cloud
|
||||
N8N_WEBHOOK_URL=https://your-n8n-instance.com
|
||||
N8N_WEBHOOK_URL=https://flow.egonetix.de/webhook/3371ad7c-0866-4161-90a4-f251de4aceb8
|
||||
|
||||
# n8n API key (if using n8n API directly)
|
||||
N8N_API_KEY=your_n8n_api_key
|
||||
@@ -171,8 +171,8 @@ TRADINGVIEW_WEBHOOK_SECRET=your_tradingview_webhook_secret
|
||||
# 1. Create bot: Message @BotFather on Telegram, send /newbot
|
||||
# 2. Get token from BotFather
|
||||
# 3. Get chat ID: Message @userinfobot or your bot, it will show your chat ID
|
||||
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||
TELEGRAM_CHAT_ID=123456789
|
||||
TELEGRAM_BOT_TOKEN=8240234365:AAEm6hg_XOm54x8ctnwpNYreFKRAEvWU3uY
|
||||
TELEGRAM_CHAT_ID=579304651
|
||||
|
||||
# Discord Webhook (good for team channels)
|
||||
# 1. Go to Discord channel settings
|
||||
@@ -305,7 +305,7 @@ NEW_RELIC_LICENSE_KEY=
|
||||
# Recommended Daily Limits:
|
||||
# - MAX_DAILY_DRAWDOWN=-150 (stop at -15% loss)
|
||||
# - MAX_TRADES_PER_HOUR=6 (prevent overtrading)
|
||||
# - MIN_TIME_BETWEEN_TRADES=600 (10min cooldown)
|
||||
# - MIN_TIME_BETWEEN_TRADES=10 (10min cooldown)
|
||||
#
|
||||
# Expected Risk Per Trade (with defaults):
|
||||
# - Max Loss: $7.50 (50 * 10 * 0.015)
|
||||
|
||||
108
CRITICAL_ISSUES_FOUND.md
Normal file
108
CRITICAL_ISSUES_FOUND.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Trading Bot v4 - Critical Issues Found & Fixes
|
||||
|
||||
## Issue Summary
|
||||
|
||||
Three critical issues discovered:
|
||||
|
||||
1. **5-minute chart triggered instead of 15-minute** - TradingView alert format issue
|
||||
2. **SL orders not cancelled after winning trade** - Race condition + order calculation bug
|
||||
3. **No runner position (20% should remain)** - TP2 size calculation bug
|
||||
|
||||
---
|
||||
|
||||
## Issue 1: Wrong Timeframe Triggered
|
||||
|
||||
### Problem
|
||||
- Trade executed on 5-minute chart signal
|
||||
- n8n workflow has correct filter for "15" timeframe
|
||||
- Filter checks: `timeframe === "15"`
|
||||
|
||||
### Root Cause
|
||||
- n8n extracts timeframe with regex: `/\.P\s+(\d+)/`
|
||||
- Looks for ".P 5" or ".P 15" in TradingView message
|
||||
- Defaults to '15' if no match found
|
||||
|
||||
### Solution
|
||||
**Check your TradingView alert message format:**
|
||||
|
||||
Your alert should include the timeframe like this:
|
||||
```
|
||||
SOL buy .P 15
|
||||
```
|
||||
|
||||
The ".P 15" tells n8n it's a 15-minute chart. If you're sending:
|
||||
```
|
||||
SOL buy .P 5
|
||||
```
|
||||
|
||||
Then n8n will reject it (correctly filtering out 5-minute signals).
|
||||
|
||||
**Verify n8n is receiving correct format by checking n8n execution logs.**
|
||||
|
||||
---
|
||||
|
||||
## Issue 2: SL Orders Not Cancelled
|
||||
|
||||
### Problem
|
||||
- After winning trade, 2 SL orders remain on Drift ($198.39 and $195.77)
|
||||
- Bot detected "position closed externally" but found "no orders to cancel"
|
||||
|
||||
### Root Cause
|
||||
**Race Condition in `/api/trading/execute`:**
|
||||
|
||||
Current order of operations:
|
||||
1. Open position ✅
|
||||
2. Add to Position Manager (starts monitoring immediately) ⚠️
|
||||
3. Place exit orders (TP1, TP2, SL) ⏰
|
||||
|
||||
If TP hits very fast (< 2-3 seconds):
|
||||
- Position Manager detects "external closure" while orders are still being placed
|
||||
- Tries to cancel orders that don't exist yet
|
||||
- Orders finish placing AFTER position is gone → orphaned orders
|
||||
|
||||
### Solution
|
||||
**Reorder operations: Place exit orders BEFORE starting monitoring**
|
||||
|
||||
---
|
||||
|
||||
## Issue 3: No Runner Position
|
||||
|
||||
### Problem
|
||||
- Config: `TAKE_PROFIT_2_SIZE_PERCENT=80` (should leave 20% runner)
|
||||
- Expected: TP1 closes 75% → TP2 closes 80% of remaining → 5% runner remains
|
||||
- Actual: Position 100% closed, no runner
|
||||
|
||||
### Root Cause
|
||||
**BUG in `/home/icke/traderv4/lib/drift/orders.ts` lines 232-233:**
|
||||
|
||||
```typescript
|
||||
const tp1USD = (options.positionSizeUSD * options.tp1SizePercent) / 100
|
||||
const tp2USD = (options.positionSizeUSD * options.tp2SizePercent) / 100
|
||||
```
|
||||
|
||||
Both TP1 and TP2 are calculated as **percentages of ORIGINAL position**, not remaining!
|
||||
|
||||
**With your settings (TP1=75%, TP2=80%, position=$80):**
|
||||
- TP1: 75% × $80 = $60 ✅
|
||||
- TP2: 80% × $80 = $64 ❌ (should be 80% × $20 remaining = $16)
|
||||
- Total: $60 + $64 = $124 (exceeds position size!)
|
||||
|
||||
Drift caps at 100%, so entire position closes.
|
||||
|
||||
### Solution
|
||||
**Fix TP2 calculation to use remaining size after TP1**
|
||||
|
||||
---
|
||||
|
||||
## Recommended Fixes
|
||||
|
||||
### Fix 1: TradingView Alert Format
|
||||
|
||||
Update your TradingView alert to include ".P 15":
|
||||
```
|
||||
{{ticker}} {{strategy.order.action}} .P 15
|
||||
```
|
||||
|
||||
### Fix 2 & 3: Code Changes
|
||||
|
||||
See next files for implementation...
|
||||
191
FIXES_APPLIED.md
Normal file
191
FIXES_APPLIED.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Fixes Applied - Trading Bot v4
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
Fixed 3 critical bugs discovered in your trading bot:
|
||||
|
||||
1. ✅ **TP2 Runner Calculation Bug** - Now correctly calculates TP2 as percentage of REMAINING position
|
||||
2. ✅ **Race Condition Fix** - Exit orders now placed BEFORE Position Manager starts monitoring
|
||||
3. ⚠️ **TradingView Timeframe** - Needs verification of alert format
|
||||
|
||||
---
|
||||
|
||||
## Fix 1: TP2 Runner Position Bug
|
||||
|
||||
### File: `lib/drift/orders.ts`
|
||||
|
||||
**Problem:**
|
||||
```typescript
|
||||
// BEFORE (WRONG):
|
||||
const tp1USD = (options.positionSizeUSD * options.tp1SizePercent) / 100 // 75% of $80 = $60
|
||||
const tp2USD = (options.positionSizeUSD * options.tp2SizePercent) / 100 // 80% of $80 = $64 ❌
|
||||
// Total: $124 (exceeds position!) → Drift closes 100%, no runner
|
||||
```
|
||||
|
||||
**Fixed:**
|
||||
```typescript
|
||||
// AFTER (CORRECT):
|
||||
const tp1USD = (options.positionSizeUSD * options.tp1SizePercent) / 100 // 75% of $80 = $60
|
||||
const remainingAfterTP1 = options.positionSizeUSD - tp1USD // $80 - $60 = $20
|
||||
const tp2USD = (remainingAfterTP1 * options.tp2SizePercent) / 100 // 80% of $20 = $16 ✅
|
||||
// Remaining: $20 - $16 = $4 (5% runner!) ✅
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- With `TAKE_PROFIT_2_SIZE_PERCENT=80`:
|
||||
- TP1 closes 75% ($60)
|
||||
- TP2 closes 80% of remaining ($16)
|
||||
- **5% runner remains** ($4) for trailing stop!
|
||||
|
||||
Added logging to verify:
|
||||
```
|
||||
📊 Exit order sizes:
|
||||
TP1: 75% of $80.00 = $60.00
|
||||
Remaining after TP1: $20.00
|
||||
TP2: 80% of remaining = $16.00
|
||||
Runner (if any): $4.00
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fix 2: Race Condition - Orphaned SL Orders
|
||||
|
||||
### File: `app/api/trading/execute/route.ts`
|
||||
|
||||
**Problem:**
|
||||
```
|
||||
Old Flow:
|
||||
1. Open position
|
||||
2. Add to Position Manager → starts monitoring immediately
|
||||
3. Place exit orders (TP1, TP2, SL)
|
||||
|
||||
If TP hits fast (< 2-3 seconds):
|
||||
- Position Manager detects "external closure"
|
||||
- Tries to cancel orders (finds none yet)
|
||||
- Orders finish placing AFTER position gone
|
||||
- Result: Orphaned SL orders on Drift!
|
||||
```
|
||||
|
||||
**Fixed:**
|
||||
```
|
||||
New Flow:
|
||||
1. Open position
|
||||
2. Place exit orders (TP1, TP2, SL) ← FIRST
|
||||
3. Add to Position Manager → starts monitoring
|
||||
|
||||
Now:
|
||||
- All orders exist before monitoring starts
|
||||
- If TP hits fast, Position Manager can cancel remaining orders
|
||||
- No orphaned orders!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Issue 3: TradingView Timeframe Filter
|
||||
|
||||
### Status: Needs Your Action
|
||||
|
||||
The n8n workflow **correctly filters** for 15-minute timeframe:
|
||||
```json
|
||||
{
|
||||
"conditions": {
|
||||
"string": [{
|
||||
"value1": "={{ $json.timeframe }}",
|
||||
"operation": "equals",
|
||||
"value2": "15"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The timeframe is extracted from your TradingView alert with regex:
|
||||
```javascript
|
||||
/\.P\s+(\d+)/ // Looks for ".P 15" or ".P 5" in message
|
||||
```
|
||||
|
||||
### Action Required:
|
||||
|
||||
**Check your TradingView alert message format.**
|
||||
|
||||
It should look like:
|
||||
```
|
||||
{{ticker}} {{strategy.order.action}} .P 15
|
||||
```
|
||||
|
||||
Examples:
|
||||
- ✅ Correct: `SOL buy .P 15` (will be accepted)
|
||||
- ❌ Wrong: `SOL buy .P 5` (will be rejected)
|
||||
- ⚠️ Missing: `SOL buy` (defaults to 15, but not explicit)
|
||||
|
||||
**To verify:**
|
||||
1. Open your TradingView chart
|
||||
2. Go to Alerts
|
||||
3. Check the alert message format
|
||||
4. Ensure it includes ".P 15" for 15-minute timeframe
|
||||
|
||||
---
|
||||
|
||||
## Testing the Fixes
|
||||
|
||||
### Test 1: Runner Position
|
||||
1. Place a test trade (or wait for next signal)
|
||||
2. Watch position in Drift
|
||||
3. TP1 should hit → 75% closes
|
||||
4. TP2 should hit → 80% of remaining closes
|
||||
5. **5% runner should remain** for trailing stop
|
||||
|
||||
Expected in Drift:
|
||||
- After TP1: 0.25 SOL remaining (from 1.0 SOL)
|
||||
- After TP2: 0.05 SOL remaining (runner)
|
||||
|
||||
### Test 2: No Orphaned Orders
|
||||
1. Place test trade
|
||||
2. If TP hits quickly, check Drift "Orders" tab
|
||||
3. Should show: **No open orders** after position fully closes
|
||||
4. Previously: 2 SL orders remained after win
|
||||
|
||||
### Test 3: Timeframe Filter
|
||||
1. Send 5-minute alert from TradingView (with ".P 5")
|
||||
2. Check n8n execution → Should be **rejected** by filter
|
||||
3. Send 15-minute alert (with ".P 15")
|
||||
4. Should be **accepted** and execute trade
|
||||
|
||||
---
|
||||
|
||||
## Build & Deploy
|
||||
|
||||
```bash
|
||||
# Build (in progress)
|
||||
docker compose build trading-bot
|
||||
|
||||
# Restart
|
||||
docker compose up -d --force-recreate trading-bot
|
||||
|
||||
# Verify
|
||||
docker logs -f trading-bot-v4
|
||||
```
|
||||
|
||||
Look for new log message:
|
||||
```
|
||||
📊 Exit order sizes:
|
||||
TP1: 75% of $XX.XX = $XX.XX
|
||||
Remaining after TP1: $XX.XX
|
||||
TP2: 80% of remaining = $XX.XX
|
||||
Runner (if any): $XX.XX
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Issue | Status | Impact |
|
||||
|-------|--------|--------|
|
||||
| TP2 Runner Calculation | ✅ Fixed | 5% runner will now remain as intended |
|
||||
| Orphaned SL Orders | ✅ Fixed | Orders placed before monitoring starts |
|
||||
| 5min vs 15min Filter | ⚠️ Verify | Check TradingView alert includes ".P 15" |
|
||||
|
||||
**Next Steps:**
|
||||
1. Deploy fixes (build running)
|
||||
2. Verify TradingView alert format
|
||||
3. Test with next trade signal
|
||||
4. Monitor for runner position and clean order cancellation
|
||||
162
FIXES_RUNNER_AND_CANCELLATION.md
Normal file
162
FIXES_RUNNER_AND_CANCELLATION.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Runner and Order Cancellation Fixes
|
||||
|
||||
## Date: 2025-01-29
|
||||
|
||||
## Issues Found and Fixed
|
||||
|
||||
### 1. **5% Runner (Trailing Stop) Not Working**
|
||||
|
||||
**Problem:**
|
||||
- Config had `takeProfit2SizePercent: 100` which closed 100% of remaining position at TP2
|
||||
- This left 0% for the runner, so trailing stop never activated
|
||||
- Logs showed "Executing TP2 for SOL-PERP (80%)" but no "Runner activated" messages
|
||||
|
||||
**Root Cause:**
|
||||
- After TP1 closes 75%, remaining position is 25%
|
||||
- TP2 at 100% closes all of that 25%, leaving nothing for trailing stop
|
||||
|
||||
**Fix Applied:**
|
||||
```typescript
|
||||
// config/trading.ts line 98
|
||||
takeProfit2SizePercent: 80, // Close 80% of remaining 25% at TP2 (leaves 5% as runner)
|
||||
```
|
||||
|
||||
**How It Works Now:**
|
||||
1. Entry: 100% position ($50)
|
||||
2. TP1 hits: Closes 75% → Leaves 25% ($12.50)
|
||||
3. TP2 hits: Closes 80% of remaining 25% (= 20% of original) → Leaves 5% ($2.50 runner)
|
||||
4. Trailing stop activates when runner reaches +0.5% profit
|
||||
5. Stop loss trails 0.3% below peak price
|
||||
|
||||
**Expected Behavior:**
|
||||
- You should now see: `🏃 Runner activated: 5.0% remaining with trailing stop`
|
||||
- Then: `📈 Trailing SL updated: $X.XX → $Y.YY (0.3% below peak $Z.ZZ)`
|
||||
- Finally: `🔴 TRAILING STOP HIT: SOL-PERP at +X.XX%`
|
||||
|
||||
---
|
||||
|
||||
### 2. **Stop-Loss Orders Not Being Canceled After Position Closes**
|
||||
|
||||
**Problem:**
|
||||
- When position closed (by software or on-chain orders), 2 SL orders remained open on Drift
|
||||
- Drift UI showed orphaned TRIGGER_MARKET and TRIGGER_LIMIT orders
|
||||
- Logs showed "Position fully closed, cancelling remaining orders..." but NO "Cancelled X orders"
|
||||
|
||||
**Root Cause:**
|
||||
```typescript
|
||||
// OLD CODE - lib/drift/orders.ts line 570
|
||||
const ordersToCancel = userAccount.orders.filter(
|
||||
(order: any) =>
|
||||
order.marketIndex === marketConfig.driftMarketIndex &&
|
||||
order.status === 0 // ❌ WRONG: Trigger orders have different status values
|
||||
)
|
||||
```
|
||||
|
||||
The filter `order.status === 0` only caught LIMIT orders in "open" state, but missed:
|
||||
- **TRIGGER_MARKET** orders (hard stop loss)
|
||||
- **TRIGGER_LIMIT** orders (soft stop loss)
|
||||
|
||||
These trigger orders have different status enum values in Drift SDK.
|
||||
|
||||
**Fix Applied:**
|
||||
```typescript
|
||||
// NEW CODE - lib/drift/orders.ts line 569-573
|
||||
const ordersToCancel = userAccount.orders.filter(
|
||||
(order: any) =>
|
||||
order.marketIndex === marketConfig.driftMarketIndex &&
|
||||
order.orderId > 0 // ✅ Active orders have orderId > 0 (catches ALL types)
|
||||
)
|
||||
```
|
||||
|
||||
**Why This Works:**
|
||||
- All active orders (LIMIT, TRIGGER_MARKET, TRIGGER_LIMIT) have `orderId > 0`
|
||||
- Inactive/cancelled orders have `orderId = 0`
|
||||
- This catches trigger orders regardless of their status enum value
|
||||
|
||||
**Expected Behavior:**
|
||||
- When position closes, you should now see:
|
||||
```
|
||||
🗑️ Position fully closed, cancelling remaining orders...
|
||||
📋 Found 2 open orders to cancel (including trigger orders)
|
||||
✅ Orders cancelled! Transaction: 5x7Y8z...
|
||||
✅ Cancelled 2 orders
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
### Test 1: Verify Runner Activation
|
||||
1. Place a test LONG trade
|
||||
2. Wait for TP1 to hit (should close 75%)
|
||||
3. Wait for TP2 to hit (should close 20%, leaving 5%)
|
||||
4. Look for logs: `🏃 Runner activated: 5.0% remaining with trailing stop`
|
||||
5. Watch for trailing stop updates as price moves
|
||||
|
||||
### Test 2: Verify Order Cancellation
|
||||
1. Place a test trade with dual stops enabled
|
||||
2. Manually close the position from Position Manager or let it hit TP2
|
||||
3. Check Docker logs for cancellation messages
|
||||
4. Verify on Drift UI that NO orders remain open for SOL-PERP
|
||||
|
||||
**Check Logs:**
|
||||
```bash
|
||||
docker logs trading-bot-v4 -f | grep -E "(Runner|Trailing|Cancelled|open orders)"
|
||||
```
|
||||
|
||||
**Check Drift Orders:**
|
||||
Go to https://app.drift.trade/ → Orders tab → Should show 0 open orders after close
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **config/trading.ts** (line 98)
|
||||
- Changed `takeProfit2SizePercent: 100` → `80`
|
||||
|
||||
2. **lib/drift/orders.ts** (lines 569-573, 579)
|
||||
- Fixed order filtering to catch trigger orders
|
||||
- Changed `order.status === 0` → `order.orderId > 0`
|
||||
- Updated log message to mention trigger orders
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
Changes deployed via:
|
||||
```bash
|
||||
docker compose build trading-bot
|
||||
docker compose up -d --force-recreate trading-bot
|
||||
```
|
||||
|
||||
Container restarted successfully at: 2025-01-29 (timestamp in logs)
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Monitor next trade** to confirm runner activates
|
||||
2. **Check Drift UI** after any close to confirm no orphaned orders
|
||||
3. **Adjust trailing stop settings** if needed:
|
||||
- `trailingStopPercent: 0.3` (current: trail 0.3% below peak)
|
||||
- `trailingStopActivation: 0.5` (current: activate at +0.5% profit)
|
||||
|
||||
---
|
||||
|
||||
## Related Configuration
|
||||
|
||||
Current trailing stop settings in `config/trading.ts`:
|
||||
```typescript
|
||||
useTrailingStop: true, // Enable trailing stop
|
||||
trailingStopPercent: 0.3, // Trail 0.3% below peak
|
||||
trailingStopActivation: 0.5, // Activate at +0.5% profit
|
||||
takeProfit1SizePercent: 75, // TP1: Close 75%
|
||||
takeProfit2SizePercent: 80, // TP2: Close 80% of remaining (= 20% total)
|
||||
// Runner: 5% remains
|
||||
```
|
||||
|
||||
**Math:**
|
||||
- Entry: 100% ($50 position)
|
||||
- After TP1: 25% remains ($12.50)
|
||||
- After TP2: 25% × (100% - 80%) = 5% remains ($2.50)
|
||||
- Runner: 5% with trailing stop
|
||||
812
README.md
812
README.md
@@ -1,6 +1,6 @@
|
||||
# Trading Bot v4 🚀
|
||||
|
||||
**Fully Autonomous Trading Bot** for TradingView → n8n → Drift Protocol (Solana)
|
||||
**Fully Autonomous Trading Bot** with Dual-Layer Redundancy for TradingView → n8n → Drift Protocol (Solana)
|
||||
|
||||
## Status
|
||||
|
||||
@@ -9,23 +9,54 @@
|
||||
| Phase 1 | ✅ **COMPLETE** | Trade execution from TradingView signals |
|
||||
| Phase 2 | ✅ **COMPLETE** | Real-time monitoring & automatic exits |
|
||||
| Phase 3 | ✅ **COMPLETE** | Web UI, settings management, Docker deployment |
|
||||
| Phase 4 | ✅ **COMPLETE** | Database integration, analytics, race condition fixes |
|
||||
|
||||
## What It Does
|
||||
|
||||
1. **Receives signals** from TradingView (5-minute chart)
|
||||
2. **Executes trades** on Drift Protocol (Solana DEX)
|
||||
3. **Monitors prices** in real-time via Pyth Network
|
||||
4. **Closes positions** automatically at TP1/TP2/SL
|
||||
5. **Adjusts stops** dynamically (breakeven, profit lock)
|
||||
6. **Provides web UI** for configuration and monitoring
|
||||
1. **Receives signals** from TradingView (5-minute OR 15-minute charts)
|
||||
2. **Executes trades** on Drift Protocol (Solana DEX) with dual stop-loss system
|
||||
3. **Monitors positions** every 2 seconds via Pyth Network WebSocket + HTTP fallback
|
||||
4. **Closes positions** automatically at TP1 (partial) / TP2 (80%) / SL (100%)
|
||||
5. **Adjusts stops** dynamically (breakeven at +0.5%, profit lock at +1.2%)
|
||||
6. **Tracks everything** in PostgreSQL (trades, prices, P&L, win rate)
|
||||
7. **Provides web UI** for configuration, monitoring, and analytics
|
||||
|
||||
**100% autonomous. No manual intervention required!**
|
||||
**100% autonomous. Dual-layer safety. No manual intervention required!**
|
||||
|
||||
## Architecture: Dual-Layer Redundancy
|
||||
|
||||
**Key Design Principle:** Every trade has **TWO independent exit mechanisms**:
|
||||
|
||||
1. **On-Chain Orders (Drift Protocol)** - Primary layer
|
||||
- TP1/TP2 as LIMIT orders
|
||||
- Soft SL as TRIGGER_LIMIT (-1.5%, avoids wicks)
|
||||
- Hard SL as TRIGGER_MARKET (-2.5%, guarantees exit)
|
||||
|
||||
2. **Software Monitoring (Position Manager)** - Backup layer
|
||||
- Checks prices every 2 seconds
|
||||
- Closes via MARKET orders if on-chain orders fail
|
||||
- Dynamic SL adjustments
|
||||
- Emergency stop functionality
|
||||
|
||||
**Why?** If Drift orders don't fill (low liquidity, network issues), Position Manager acts as backup. Both write to the same PostgreSQL database for complete trade history.
|
||||
|
||||
## Quick Start (Docker)
|
||||
|
||||
### 1. Deploy with Docker Compose
|
||||
### 1. Prerequisites
|
||||
- Docker & Docker Compose installed
|
||||
- Solana wallet with Drift Protocol account
|
||||
- Helius RPC API key (mainnet)
|
||||
- TradingView alerts → n8n webhook setup
|
||||
|
||||
### 2. Deploy with Docker Compose
|
||||
```bash
|
||||
# Build and start
|
||||
# Clone and setup
|
||||
cd /home/icke/traderv4
|
||||
|
||||
# Configure .env file (copy from .env.example)
|
||||
nano .env
|
||||
|
||||
# Build and start all services
|
||||
docker compose up -d
|
||||
|
||||
# View logs
|
||||
@@ -35,20 +66,27 @@ docker compose logs -f trading-bot
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### 2. Access Web Interface
|
||||
### 3. Access Web Interface
|
||||
- **Settings UI:** `http://YOUR_HOST:3001/settings`
|
||||
- **Analytics:** `http://YOUR_HOST:3001/analytics`
|
||||
- **API Endpoints:** `http://YOUR_HOST:3001/api/`
|
||||
|
||||
### 3. Configure Settings
|
||||
### 4. Configure Settings
|
||||
Open `http://YOUR_HOST:3001/settings` in your browser to:
|
||||
- Adjust position size and leverage
|
||||
- Set stop-loss and take-profit levels
|
||||
- Configure dynamic stop-loss triggers
|
||||
- Set daily loss limits
|
||||
- Toggle DRY_RUN mode
|
||||
- Adjust position size ($10-$10,000) and leverage (1x-20x)
|
||||
- Set stop-loss (-1.5% soft, -2.5% hard) and take-profit levels
|
||||
- Configure dynamic stop-loss (breakeven +0.5%, profit lock +1.2%)
|
||||
- Set daily loss limits and max trades per hour
|
||||
- Toggle DRY_RUN mode for paper trading
|
||||
|
||||
### 4. Setup n8n Workflow
|
||||
Import `n8n-complete-workflow.json` into your n8n instance and configure TradingView alerts.
|
||||
After saving, click **"Restart Bot"** to apply changes.
|
||||
|
||||
### 5. Setup n8n Workflow
|
||||
Import `workflows/trading/Money_Machine.json` into your n8n instance:
|
||||
- Configure TradingView webhook URL
|
||||
- Set timeframe filters (5min and/or 15min)
|
||||
- Add API authentication header
|
||||
- Test with manual execution
|
||||
|
||||
## Alternative: Manual Setup
|
||||
|
||||
@@ -81,31 +119,159 @@ npm start
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
## Core Features
|
||||
|
||||
### Phase 1: Trade Execution ✅
|
||||
- Drift Protocol integration
|
||||
- Market order execution
|
||||
- TradingView signal normalization
|
||||
- n8n webhook endpoint
|
||||
- Risk validation API
|
||||
### Dual Stop-Loss System
|
||||
- **Soft Stop** (TRIGGER_LIMIT): -1.5% from entry, avoids wick-outs
|
||||
- **Hard Stop** (TRIGGER_MARKET): -2.5% from entry, guarantees exit
|
||||
- Both placed on-chain as reduce-only orders
|
||||
- Position Manager monitors as backup (closes via MARKET if needed)
|
||||
|
||||
### Phase 2: Autonomous Trading ✅
|
||||
- **Pyth price monitoring** (WebSocket + polling)
|
||||
- **Position manager** (tracks all trades)
|
||||
- **Automatic exits** (TP1/TP2/SL/Emergency)
|
||||
- **Dynamic SL** (breakeven + profit lock)
|
||||
- **Multi-position** support
|
||||
- **Real-time P&L** tracking
|
||||
### Dynamic Stop-Loss Adjustments
|
||||
- **Breakeven**: Moves SL to +0.01% when price hits +0.5%
|
||||
- **Profit Lock**: Locks in profit when price hits +1.2%
|
||||
- **Reduces risk** while letting winners run
|
||||
|
||||
### Phase 3: Production Ready ✅
|
||||
- **Web UI** for settings management
|
||||
- **Docker deployment** with multi-stage builds
|
||||
- **REST API** for all operations
|
||||
- **Risk calculator** with live preview
|
||||
- **Settings persistence** to .env file
|
||||
- **PostgreSQL** integration ready
|
||||
### Take-Profit Strategy
|
||||
- **TP1** (default +0.7%): Closes 50% of position, moves SL to breakeven
|
||||
- **TP2** (default +1.5%): Closes 80% of remaining position
|
||||
- **Runner**: 20% remains open if takeProfit2SizePercent < 100%
|
||||
|
||||
### Position Manager (Singleton)
|
||||
- Monitors all positions every 2 seconds
|
||||
- Singleton pattern: Use `getPositionManager()` - never instantiate directly
|
||||
- Tracks price updates in database
|
||||
- Closes positions when targets hit
|
||||
- Cancels orphaned orders automatically
|
||||
- Acts as backup if on-chain orders don't fill
|
||||
|
||||
### Database Integration (PostgreSQL + Prisma)
|
||||
**Models:**
|
||||
- `Trade`: Complete trade history with entry/exit data
|
||||
- `PriceUpdate`: Price movements every 2 seconds during monitoring
|
||||
- `SystemEvent`: Errors, restarts, important events
|
||||
- `DailyStats`: Win rate, profit factor, avg win/loss
|
||||
|
||||
**Analytics:**
|
||||
- Real-time P&L tracking
|
||||
- Win rate and profit factor
|
||||
- Best/worst trades
|
||||
- Drawdown monitoring
|
||||
|
||||
### Configuration System (Three-Layer Merge)
|
||||
1. **Defaults** (`config/trading.ts` - DEFAULT_TRADING_CONFIG)
|
||||
2. **Environment** (`.env` file via `getConfigFromEnv()`)
|
||||
3. **Runtime** (API overrides via `getMergedConfig(overrides)`)
|
||||
|
||||
Always use `getMergedConfig()` in business logic - never read env vars directly.
|
||||
|
||||
### Safety Features
|
||||
- **Reduce-only orders**: All TP/SL orders can only close, not open positions
|
||||
- **Account health checks**: Validates margin before every trade
|
||||
- **Risk validation**: `/api/trading/check-risk` endpoint
|
||||
- **Daily loss limits**: Stops trading after max loss reached
|
||||
- **Cooldown periods**: Prevents over-trading
|
||||
- **DRY_RUN mode**: Paper trading for testing
|
||||
|
||||
---
|
||||
|
||||
## How It Works: Complete Trade Flow
|
||||
|
||||
### 1. Signal Reception (TradingView → n8n)
|
||||
```
|
||||
TradingView Alert: "LONG SOLUSDT .P 15"
|
||||
↓
|
||||
n8n Webhook receives signal
|
||||
↓
|
||||
Parse Signal node extracts:
|
||||
- Symbol: SOLUSDT → SOL-PERP (normalized)
|
||||
- Direction: long
|
||||
- Timeframe: 15 (from .P 15)
|
||||
↓
|
||||
Timeframe Filter: Allow 5 or 15 minutes only
|
||||
↓
|
||||
Check Risk: Validate position limits, daily loss, etc.
|
||||
```
|
||||
|
||||
### 2. Trade Execution (n8n → Next.js API → Drift)
|
||||
```
|
||||
POST /api/trading/execute
|
||||
↓
|
||||
getMergedConfig() - Get current settings
|
||||
↓
|
||||
initializeDriftService() - Connect to Drift SDK
|
||||
↓
|
||||
Check account health (margin requirements)
|
||||
↓
|
||||
openPosition() - Execute MARKET order
|
||||
↓
|
||||
Calculate dual stop prices (soft -1.5%, hard -2.5%)
|
||||
↓
|
||||
placeExitOrders() - Place on-chain TP/SL orders
|
||||
├─ TP1: LIMIT reduce-only at +0.7%
|
||||
├─ TP2: LIMIT reduce-only at +1.5%
|
||||
├─ Soft SL: TRIGGER_LIMIT reduce-only at -1.5%
|
||||
└─ Hard SL: TRIGGER_MARKET reduce-only at -2.5%
|
||||
↓
|
||||
createTrade() - Save to PostgreSQL database
|
||||
↓
|
||||
positionManager.addTrade() - Start monitoring loop
|
||||
```
|
||||
|
||||
### 3. Position Monitoring (Every 2 Seconds)
|
||||
```
|
||||
Position Manager Loop:
|
||||
↓
|
||||
getPythPriceMonitor().getLatestPrice()
|
||||
↓
|
||||
Calculate current P&L and percentage gain/loss
|
||||
↓
|
||||
Check TP1 hit? → closePosition(75%) + move SL to breakeven
|
||||
↓
|
||||
Check TP2 hit? → closePosition(80% of remaining)
|
||||
↓
|
||||
Check SL hit? → closePosition(100%)
|
||||
↓
|
||||
Check dynamic adjustments:
|
||||
├─ Price > +0.5%? → Move SL to breakeven (+0.01%)
|
||||
└─ Price > +1.2%? → Lock profit (move SL to +X%)
|
||||
↓
|
||||
addPriceUpdate() - Save price to database
|
||||
↓
|
||||
Repeat every 2 seconds until position closed
|
||||
```
|
||||
|
||||
### 4. Position Exit (Automatic)
|
||||
```
|
||||
Exit Triggered (TP/SL hit):
|
||||
↓
|
||||
If closed by on-chain order:
|
||||
├─ Position Manager detects position.size === 0
|
||||
├─ Determines exit reason (TP1/TP2/SL from price)
|
||||
├─ updateTradeExit() - Save exit data to database
|
||||
└─ removeTrade() - Stop monitoring + cancel orphaned orders
|
||||
↓
|
||||
If closed by Position Manager:
|
||||
├─ closePosition() - Execute MARKET order
|
||||
├─ cancelAllOrders() - Cancel remaining on-chain orders
|
||||
├─ updateTradeExit() - Save exit data to database
|
||||
└─ removeTrade() - Stop monitoring
|
||||
```
|
||||
|
||||
### 5. Order Cleanup (Automatic)
|
||||
```
|
||||
When position closes (100%):
|
||||
↓
|
||||
cancelAllOrders(symbol) - Query all open orders
|
||||
↓
|
||||
Filter by marketIndex and status === 0 (Open)
|
||||
↓
|
||||
driftClient.cancelOrders() - Cancel on Drift
|
||||
↓
|
||||
Logs: "Cancelled X orders" or "Cancelled X orphaned orders"
|
||||
```
|
||||
|
||||
**Result:** Clean exit with no orphaned orders, complete trade history in database, ready for next signal.
|
||||
---
|
||||
|
||||
## Web Interface
|
||||
@@ -118,13 +284,13 @@ Beautiful web interface for managing all trading parameters:
|
||||
- Set leverage (1x-20x)
|
||||
|
||||
**Risk Management:**
|
||||
- Stop-loss percentage
|
||||
- Stop-loss percentage (soft -1.5%, hard -2.5%)
|
||||
- Take-profit 1 & 2 levels
|
||||
- Emergency stop level
|
||||
|
||||
**Dynamic Stop-Loss:**
|
||||
- Breakeven trigger
|
||||
- Profit lock trigger and amount
|
||||
- Breakeven trigger (+0.5%)
|
||||
- Profit lock trigger and amount (+1.2%)
|
||||
|
||||
**Safety Limits:**
|
||||
- Max daily loss
|
||||
@@ -140,30 +306,48 @@ Beautiful web interface for managing all trading parameters:
|
||||
- TP1 and TP2 gains
|
||||
- Risk/Reward ratio
|
||||
|
||||
### Analytics Page (`/analytics`)
|
||||
Real-time trading performance dashboard:
|
||||
- Current open positions with live P&L
|
||||
- Trade history with detailed entry/exit data
|
||||
- Win rate and profit factor
|
||||
- Total P&L (daily, weekly, monthly)
|
||||
- Best and worst trades
|
||||
- Drawdown tracking
|
||||
|
||||
### API Endpoints
|
||||
|
||||
All endpoints require `Authorization: Bearer YOUR_API_SECRET_KEY`
|
||||
All endpoints require `Authorization: Bearer YOUR_API_SECRET_KEY` (except `/api/trading/test`)
|
||||
|
||||
**Trade Execution:**
|
||||
```bash
|
||||
# Execute a trade
|
||||
# Execute a trade (production - from n8n)
|
||||
POST /api/trading/execute
|
||||
{
|
||||
"symbol": "SOL-PERP",
|
||||
"direction": "long",
|
||||
"timeframe": "5",
|
||||
"timeframe": "15",
|
||||
"signalStrength": "strong"
|
||||
}
|
||||
|
||||
# Close a position
|
||||
# Test trade (no auth required - from settings UI)
|
||||
POST /api/trading/test
|
||||
{
|
||||
"symbol": "SOL-PERP",
|
||||
"direction": "long",
|
||||
"timeframe": "15"
|
||||
}
|
||||
|
||||
# Close a position (partial or full)
|
||||
POST /api/trading/close
|
||||
{
|
||||
"symbol": "SOL-PERP",
|
||||
"percentToClose": 100
|
||||
"percentToClose": 100 // or 50, 75, etc.
|
||||
}
|
||||
|
||||
# Get active positions
|
||||
GET /api/trading/positions
|
||||
# Returns: { positions: [...], monitoring: [...] }
|
||||
|
||||
# Validate trade (risk check)
|
||||
POST /api/trading/check-risk
|
||||
@@ -178,69 +362,118 @@ POST /api/trading/check-risk
|
||||
# Get current settings
|
||||
GET /api/settings
|
||||
|
||||
# Update settings
|
||||
# Update settings (writes to .env file)
|
||||
POST /api/settings
|
||||
{
|
||||
"MAX_POSITION_SIZE_USD": 100,
|
||||
"LEVERAGE": 10,
|
||||
"STOP_LOSS_PERCENT": -1.5,
|
||||
...
|
||||
"SOFT_STOP_LOSS_PERCENT": -1.5,
|
||||
"HARD_STOP_LOSS_PERCENT": -2.5,
|
||||
"TAKE_PROFIT_1_PERCENT": 0.7,
|
||||
"TAKE_PROFIT_2_PERCENT": 1.5,
|
||||
"TAKE_PROFIT_2_SIZE_PERCENT": 80,
|
||||
"BREAKEVEN_TRIGGER_PERCENT": 0.5,
|
||||
"PROFIT_LOCK_TRIGGER_PERCENT": 1.2,
|
||||
"DRY_RUN": false
|
||||
}
|
||||
|
||||
# Restart bot container (apply settings)
|
||||
POST /api/restart
|
||||
# Creates /tmp/trading-bot-restart.flag
|
||||
# watch-restart.sh detects flag and runs: docker restart trading-bot-v4
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Settings changes require container restart to take effect
|
||||
- Use the web UI's "Restart Bot" button or call `/api/restart`
|
||||
- Restart watcher must be running (see setup below)
|
||||
**Analytics:**
|
||||
```bash
|
||||
# Get trade statistics
|
||||
GET /api/analytics/stats
|
||||
# Returns: { winRate, profitFactor, totalTrades, totalPnL, ... }
|
||||
|
||||
# Update settings
|
||||
POST /api/settings
|
||||
{
|
||||
"MAX_POSITION_SIZE_USD": 100,
|
||||
"LEVERAGE": 5,
|
||||
"STOP_LOSS_PERCENT": -1.5,
|
||||
...
|
||||
}
|
||||
# Get recent trades
|
||||
GET /api/analytics/positions
|
||||
# Returns: { openPositions: [...], recentTrades: [...] }
|
||||
```
|
||||
|
||||
**Symbol Normalization:**
|
||||
- TradingView sends: `SOLUSDT`, `BTCUSDT`, `ETHUSDT`
|
||||
- Bot converts to: `SOL-PERP`, `BTC-PERP`, `ETH-PERP`
|
||||
- Always use Drift format in API calls
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Architecture
|
||||
- **Multi-stage build** for optimized image size
|
||||
- **Next.js standalone** output for production
|
||||
- **PostgreSQL** for trade history
|
||||
- **Multi-stage build**: deps → builder → runner (Node 20 Alpine)
|
||||
- **Next.js standalone** output for production (~400MB image)
|
||||
- **PostgreSQL 16-alpine** for trade history
|
||||
- **Isolated network** (172.28.0.0/16)
|
||||
- **Health monitoring** and logging
|
||||
|
||||
### Container Details
|
||||
- **Port:** 3001 (external) → 3000 (internal)
|
||||
- **Image:** Node 20 Alpine
|
||||
- **Size:** ~400MB (optimized)
|
||||
- **Restart:** unless-stopped
|
||||
- **trading-bot-v4**: Main application
|
||||
- Port: 3001 (external) → 3000 (internal)
|
||||
- Restart: unless-stopped
|
||||
- Volumes: .env file mounted
|
||||
|
||||
- **trading-bot-postgres**: Database
|
||||
- Port: 5432 (internal only)
|
||||
- Persistent volume: trading-bot-postgres-data
|
||||
- Auto-backup recommended
|
||||
|
||||
### Critical Build Steps
|
||||
1. Install deps: `npm install --production`
|
||||
2. Copy source and generate Prisma client: `npx prisma generate`
|
||||
3. Build Next.js: `npm run build` (standalone mode)
|
||||
4. Runner stage: Copy standalone + static + node_modules + Prisma client
|
||||
|
||||
**Why Prisma generate before build?** The Trade type from Prisma must exist before Next.js compiles TypeScript.
|
||||
|
||||
### Commands
|
||||
```bash
|
||||
# Build image
|
||||
# Build and deploy
|
||||
docker compose build trading-bot
|
||||
|
||||
# Start services
|
||||
docker compose up -d
|
||||
|
||||
# View logs
|
||||
# View logs (real-time)
|
||||
docker compose logs -f trading-bot
|
||||
|
||||
# View logs (last 100 lines)
|
||||
docker compose logs --tail=100 trading-bot
|
||||
|
||||
# Restart after config changes
|
||||
docker compose restart trading-bot
|
||||
|
||||
# Rebuild and restart (force recreate)
|
||||
docker compose up -d --force-recreate trading-bot
|
||||
|
||||
# Stop everything
|
||||
docker compose down
|
||||
|
||||
# Stop and remove volumes (WARNING: deletes database)
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
### Database Operations
|
||||
```bash
|
||||
# Connect to database
|
||||
docker exec -it trading-bot-postgres psql -U postgres -d trading_bot_v4
|
||||
|
||||
# Run migrations from host
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/trading_bot_v4" npx prisma migrate dev
|
||||
|
||||
# Generate Prisma client
|
||||
npx prisma generate
|
||||
|
||||
# View tables
|
||||
docker exec trading-bot-postgres psql -U postgres -d trading_bot_v4 -c "\dt"
|
||||
```
|
||||
|
||||
**DATABASE_URL caveat:** Use `trading-bot-postgres` (container name) in .env for runtime, but `localhost:5432` for Prisma CLI migrations from host.
|
||||
|
||||
### Restart Watcher (Required for Web UI Restart Button)
|
||||
The restart watcher monitors for restart requests from the web UI:
|
||||
|
||||
@@ -262,15 +495,54 @@ sudo systemctl status trading-bot-restart-watcher
|
||||
The watcher enables the "Restart Bot" button in the web UI to automatically restart the container when settings are changed.
|
||||
|
||||
### Environment Variables
|
||||
All settings are configured via `.env` file:
|
||||
- Drift wallet credentials
|
||||
- Solana RPC endpoint (Helius recommended)
|
||||
- Trading parameters (size, leverage, SL, TP)
|
||||
- Risk limits and safety controls
|
||||
- API authentication key
|
||||
All settings configured via `.env` file:
|
||||
|
||||
**Required:**
|
||||
- `DRIFT_WALLET_PRIVATE_KEY`: Solana wallet (JSON array or base58 string)
|
||||
- `SOLANA_RPC_URL`: Helius RPC endpoint (mainnet recommended)
|
||||
- `API_SECRET_KEY`: Random secret for API authentication
|
||||
- `DATABASE_URL`: PostgreSQL connection string
|
||||
|
||||
**Trading Parameters:**
|
||||
- `MAX_POSITION_SIZE_USD`: Position size in USD
|
||||
- `LEVERAGE`: Leverage multiplier (1-20x)
|
||||
- `STOP_LOSS_PERCENT`: Soft stop percentage (e.g., -1.5)
|
||||
- `HARD_STOP_LOSS_PERCENT`: Hard stop percentage (e.g., -2.5)
|
||||
- `TAKE_PROFIT_1_PERCENT`: TP1 target (e.g., 0.7)
|
||||
- `TAKE_PROFIT_2_PERCENT`: TP2 target (e.g., 1.5)
|
||||
- `TAKE_PROFIT_2_SIZE_PERCENT`: How much to close at TP2 (e.g., 80)
|
||||
|
||||
**Dynamic Stop-Loss:**
|
||||
- `BREAKEVEN_TRIGGER_PERCENT`: Move to breakeven at (e.g., 0.5)
|
||||
- `PROFIT_LOCK_TRIGGER_PERCENT`: Lock profit at (e.g., 1.2)
|
||||
- `PROFIT_LOCK_AMOUNT_PERCENT`: Profit to lock (e.g., 0.5)
|
||||
|
||||
**Safety:**
|
||||
- `MAX_DAILY_LOSS`: Max loss per day in USD
|
||||
- `MAX_TRADES_PER_HOUR`: Rate limiting
|
||||
- `TRADE_COOLDOWN_MINUTES`: Cooldown between trades
|
||||
- `DRY_RUN`: Enable paper trading (true/false)
|
||||
- `USE_DUAL_STOPS`: Enable dual stop system (true/false)
|
||||
|
||||
Changes to `.env` require container restart to take effect.
|
||||
|
||||
### Singleton Services (Critical Pattern)
|
||||
**Never create multiple instances** - always use getter functions:
|
||||
|
||||
```typescript
|
||||
// Drift Client
|
||||
const driftService = await initializeDriftService() // NOT: new DriftService()
|
||||
const driftService = getDriftService() // After init
|
||||
|
||||
// Position Manager
|
||||
const positionManager = getPositionManager() // NOT: new PositionManager()
|
||||
|
||||
// Database
|
||||
const prisma = getPrismaClient() // NOT: new PrismaClient()
|
||||
```
|
||||
|
||||
Creating multiple instances causes connection issues and state inconsistencies.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
@@ -278,42 +550,95 @@ Changes to `.env` require container restart to take effect.
|
||||
```
|
||||
traderv4/
|
||||
├── README.md ← You are here
|
||||
├── DOCKER.md ← Docker deployment guide
|
||||
├── SETUP.md ← Setup instructions
|
||||
├── TESTING.md ← Testing guide
|
||||
├── docker-compose.yml ← Docker orchestration
|
||||
├── Dockerfile ← Multi-stage build
|
||||
├── .env ← Configuration (template)
|
||||
├── package.json ← Dependencies
|
||||
├── .env ← Configuration (create from .env.example)
|
||||
├── package.json ← Dependencies (Next.js 15, Drift SDK, Prisma)
|
||||
├── next.config.js ← Next.js config (standalone output)
|
||||
├── tsconfig.json ← TypeScript config
|
||||
│
|
||||
├── app/
|
||||
│ ├── layout.tsx ← Root layout
|
||||
│ ├── globals.css ← Tailwind styles
|
||||
├── app/ ← Next.js 15 App Router
|
||||
│ ├── layout.tsx ← Root layout with Tailwind
|
||||
│ ├── page.tsx ← Home page
|
||||
│ ├── globals.css ← Global styles
|
||||
│ ├── settings/
|
||||
│ │ └── page.tsx ← Settings UI
|
||||
│ ├── analytics/
|
||||
│ │ └── page.tsx ← Analytics dashboard
|
||||
│ └── api/
|
||||
│ ├── settings/
|
||||
│ │ └── route.ts ← Settings API
|
||||
│ │ └── route.ts ← GET/POST settings, writes to .env
|
||||
│ ├── restart/
|
||||
│ │ └── route.ts ← Creates restart flag file
|
||||
│ ├── analytics/
|
||||
│ │ ├── stats/route.ts ← Trade statistics
|
||||
│ │ └── positions/route.ts ← Open/recent positions
|
||||
│ └── trading/
|
||||
│ ├── execute/route.ts ← Execute trades
|
||||
│ ├── execute/route.ts ← Main execution (production)
|
||||
│ ├── test/route.ts ← Test execution (UI)
|
||||
│ ├── close/route.ts ← Close positions
|
||||
│ ├── positions/route.ts ← Query positions
|
||||
│ └── check-risk/route.ts ← Risk validation
|
||||
│ ├── check-risk/route.ts ← Risk validation
|
||||
│ └── remove-position/route.ts ← Remove from monitoring
|
||||
│
|
||||
├── lib/
|
||||
├── lib/ ← Business logic
|
||||
│ ├── drift/
|
||||
│ │ ├── client.ts ← Drift SDK wrapper
|
||||
│ │ └── orders.ts ← Order execution
|
||||
│ │ ├── client.ts ← Drift SDK wrapper (singleton)
|
||||
│ │ └── orders.ts ← Order execution & cancellation
|
||||
│ ├── pyth/
|
||||
│ │ └── price-monitor.ts ← Real-time prices
|
||||
│ └── trading/
|
||||
│ └── position-manager.ts ← Auto-exit logic
|
||||
│ │ └── price-monitor.ts ← WebSocket + HTTP fallback
|
||||
│ ├── trading/
|
||||
│ │ └── position-manager.ts ← Monitoring loop (singleton)
|
||||
│ ├── database/
|
||||
│ │ ├── trades.ts ← Trade CRUD operations
|
||||
│ │ └── views.ts ← Analytics queries
|
||||
│ └── notifications/
|
||||
│ └── telegram.ts ← Telegram alerts (optional)
|
||||
│
|
||||
├── config/
|
||||
│ └── trading.ts ← Market configurations
|
||||
│ └── trading.ts ← Market configs & defaults
|
||||
│
|
||||
├── n8n-complete-workflow.json ← Full n8n workflow
|
||||
└── n8n-trader-workflow.json ← Alternative workflow
|
||||
├── prisma/
|
||||
│ ├── schema.prisma ← Database models
|
||||
│ └── migrations/ ← Migration history
|
||||
│ ├── 20251026200052_init/
|
||||
│ └── 20251027080947_add_test_trade_flag/
|
||||
│
|
||||
├── workflows/ ← n8n workflow JSON files
|
||||
│ ├── trading/
|
||||
│ │ └── Money_Machine.json ← Main trading workflow
|
||||
│ ├── analytics/
|
||||
│ │ ├── n8n-daily-report.json
|
||||
│ │ ├── n8n-database-analytics.json
|
||||
│ │ └── n8n-stop-loss-analysis.json
|
||||
│ └── telegram/
|
||||
│ └── telegram-webhook-FINAL.json
|
||||
│
|
||||
├── scripts/ ← Utility scripts
|
||||
│ ├── docker-build.sh
|
||||
│ ├── docker-start.sh
|
||||
│ ├── docker-stop.sh
|
||||
│ ├── docker-logs.sh
|
||||
│ ├── watch-restart.sh ← Restart watcher daemon
|
||||
│ ├── send_trade.sh ← Test trade execution
|
||||
│ └── test-exit-orders.sh ← Test exit order placement
|
||||
│
|
||||
├── tests/ ← Test files
|
||||
│ ├── test-drift-v4.ts
|
||||
│ ├── test-full-flow.ts
|
||||
│ ├── test-position-manager.ts
|
||||
│ └── test-price-monitor.ts
|
||||
│
|
||||
├── docs/ ← Documentation
|
||||
│ ├── SETUP.md ← Detailed setup guide
|
||||
│ ├── DOCKER.md ← Docker deployment
|
||||
│ ├── TESTING.md ← Testing guide
|
||||
│ ├── TELEGRAM_BOT_README.md ← Telegram setup
|
||||
│ ├── N8N_WORKFLOW_SETUP.md ← n8n configuration
|
||||
│ ├── PHASE_2_COMPLETE.md ← Phase 2 features
|
||||
│ └── QUICKREF_PHASE2.md ← Quick reference
|
||||
│
|
||||
└── logs/ ← Log files (created at runtime)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -323,55 +648,249 @@ traderv4/
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| `README.md` | This overview |
|
||||
| `QUICKREF_PHASE2.md` | Quick reference card |
|
||||
| `SETUP.md` | Detailed setup instructions |
|
||||
| `TESTING.md` | Comprehensive testing guide |
|
||||
| `PHASE_2_COMPLETE.md` | Phase 2 feature overview |
|
||||
| `PHASE_2_SUMMARY.md` | Detailed Phase 2 summary |
|
||||
|
||||
**Root documentation:**
|
||||
- `../TRADING_BOT_V4_MANUAL.md` - Complete manual
|
||||
- `../QUICKSTART_V4.md` - Quick start guide
|
||||
| `docs/setup/SETUP.md` | Detailed setup instructions |
|
||||
| `docs/setup/DOCKER.md` | Docker deployment guide |
|
||||
| `docs/setup/TELEGRAM_BOT_README.md` | Telegram bot setup |
|
||||
| `docs/guides/TESTING.md` | Comprehensive testing guide |
|
||||
| `docs/history/PHASE_2_COMPLETE.md` | Phase 2 feature overview |
|
||||
| `workflows/trading/` | n8n workflow files |
|
||||
- `../N8N_SETUP_GUIDE.md` - n8n configuration
|
||||
|
||||
---
|
||||
|
||||
## Trade Example
|
||||
## Trade Example (Real-World Flow)
|
||||
|
||||
### Entry Signal
|
||||
### Entry Signal from TradingView
|
||||
```
|
||||
TradingView: LONG SOL @ $140.00
|
||||
Position: $1,000 (10x = $10,000)
|
||||
SL: $137.90 (-1.5%)
|
||||
TP1: $140.98 (+0.7%)
|
||||
TP2: $142.10 (+1.5%)
|
||||
Alert Message: "LONG SOLUSDT .P 15"
|
||||
↓
|
||||
n8n receives webhook
|
||||
↓
|
||||
Parse: symbol=SOL-PERP, direction=long, timeframe=15
|
||||
↓
|
||||
Timeframe check: 15 minutes ✅ (allowed)
|
||||
↓
|
||||
Risk check: Daily loss OK, no existing position ✅
|
||||
↓
|
||||
Execute trade via API
|
||||
```
|
||||
|
||||
### TP1 Hit
|
||||
### Position Opened
|
||||
```
|
||||
✅ Price reaches $140.98
|
||||
→ Auto-close 50% (+$70)
|
||||
→ Move SL to $140.21 (breakeven)
|
||||
→ Trade is now RISK-FREE
|
||||
Symbol: SOL-PERP
|
||||
Direction: LONG
|
||||
Entry: $200.00
|
||||
Position Size: $100 (10x leverage = $1,000 notional)
|
||||
|
||||
On-Chain Orders Placed:
|
||||
├─ TP1: LIMIT at $201.40 (+0.7%) - Close 50%
|
||||
├─ TP2: LIMIT at $203.00 (+1.5%) - Close 80% of remaining
|
||||
├─ Soft SL: TRIGGER_LIMIT at $197.00 (-1.5%)
|
||||
└─ Hard SL: TRIGGER_MARKET at $195.00 (-2.5%)
|
||||
|
||||
Position Manager: ✅ Monitoring started (every 2s)
|
||||
Database: ✅ Trade #601 saved
|
||||
```
|
||||
|
||||
### TP2 Hit
|
||||
### TP1 Hit (First Target)
|
||||
```
|
||||
✅ Price reaches $142.10
|
||||
→ Auto-close remaining 50% (+$150)
|
||||
→ Total P&L: +$220 (+22% account)
|
||||
→ Trade complete!
|
||||
Price reaches $201.40
|
||||
↓
|
||||
On-chain TP1 order fills → 50% closed
|
||||
↓
|
||||
Position Manager detects partial close:
|
||||
├─ Profit: +$7.00 (+7% account)
|
||||
├─ Remaining: 50% ($500 notional)
|
||||
├─ Move SL to breakeven: $200.02 (+0.01%)
|
||||
└─ Trade is now RISK-FREE ✅
|
||||
↓
|
||||
Database updated: exitReason=TP1_PARTIAL
|
||||
```
|
||||
|
||||
### Price Continues Higher
|
||||
```
|
||||
Price reaches $202.44 (+1.22%)
|
||||
↓
|
||||
Position Manager dynamic adjustment:
|
||||
├─ Trigger: +1.2% profit lock activated
|
||||
├─ Move SL to: $201.00 (+0.5% profit locked)
|
||||
└─ Letting winner run with locked profit ✅
|
||||
```
|
||||
|
||||
### TP2 Hit (Second Target)
|
||||
```
|
||||
Price reaches $203.00
|
||||
↓
|
||||
On-chain TP2 order fills → 80% of remaining closed (40% of original)
|
||||
↓
|
||||
Position Manager detects:
|
||||
├─ Profit from TP2: +$6.00
|
||||
├─ Remaining: 10% runner ($100 notional)
|
||||
└─ Runner continues with locked profit SL
|
||||
↓
|
||||
Database updated: exitReason=TP2_PARTIAL
|
||||
```
|
||||
|
||||
### Final Exit (Runner)
|
||||
```
|
||||
Option 1: Runner hits new high → Manual/trailing stop
|
||||
Option 2: Price pulls back → Locked profit SL hits at $201.00
|
||||
Option 3: Emergency stop or manual close
|
||||
|
||||
Total P&L:
|
||||
├─ TP1: +$7.00 (50% at +0.7%)
|
||||
├─ TP2: +$6.00 (40% at +1.5%)
|
||||
└─ Runner: +$3.00 (10% at +3.0%, closed at pullback)
|
||||
═══════════════════
|
||||
Total: +$16.00 (+16% account growth)
|
||||
|
||||
Database: ✅ Trade #601 complete
|
||||
```
|
||||
|
||||
### If Stop-Loss Hit Instead
|
||||
```
|
||||
Price drops to $197.00
|
||||
↓
|
||||
Soft SL (TRIGGER_LIMIT) activates:
|
||||
├─ Triggers at $197.00
|
||||
├─ Limit order at $196.95 (avoid wick)
|
||||
└─ If fills → Position closed ✅
|
||||
↓
|
||||
If soft SL doesn't fill (low liquidity):
|
||||
├─ Price drops to $195.00
|
||||
├─ Hard SL (TRIGGER_MARKET) activates
|
||||
└─ Market order guarantees exit ✅
|
||||
↓
|
||||
Position Manager backup:
|
||||
├─ Detects position still open at -2.5%
|
||||
└─ Closes via MARKET order if needed
|
||||
↓
|
||||
Loss: -$25.00 (-2.5% account)
|
||||
Database: ✅ exitReason=STOP_LOSS
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Dual stop system ensures exit even in volatile markets
|
||||
- Position Manager acts as backup to on-chain orders
|
||||
- Dynamic SL makes trades risk-free after +0.5%
|
||||
- Profit lock captures gains before reversals
|
||||
- Complete audit trail in database
|
||||
|
||||
---
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### "Drift service not initialized"
|
||||
**Cause:** Drift client not connected before trade execution
|
||||
**Solution:** Ensure `initializeDriftService()` called before operations
|
||||
|
||||
### "Position not found in monitoring"
|
||||
**Cause:** Race condition - orders placed after Position Manager started
|
||||
**Solution:** ✅ FIXED - Orders now placed before monitoring starts
|
||||
|
||||
### "Orphaned orders after exit"
|
||||
**Cause:** Old bug - Position Manager detected closure before orders existed
|
||||
**Solution:** ✅ FIXED - Order placement sequencing corrected
|
||||
**Cleanup:** Cancel manually on Drift UI or wait for next trade's auto-cleanup
|
||||
|
||||
### "TP2 closes entire position instead of 80%"
|
||||
**Cause:** Old bug - TP2 calculated from original position instead of remaining
|
||||
**Solution:** ✅ FIXED - TP2 now calculates from remaining after TP1
|
||||
|
||||
### "Database save failed but trade executed"
|
||||
**Cause:** PostgreSQL connection issue or schema mismatch
|
||||
**Solution:** Trade still executes successfully, check database logs and fix connection
|
||||
|
||||
### "Prisma Client not generated" (Docker build)
|
||||
**Cause:** `npx prisma generate` not run before `npm run build`
|
||||
**Solution:** Ensure Dockerfile runs Prisma generate in builder stage
|
||||
|
||||
### "Wrong DATABASE_URL" (localhost vs container)
|
||||
**Container runtime:** Use `trading-bot-postgres:5432` in .env
|
||||
**Prisma CLI (host):** Use `localhost:5432` for migrations
|
||||
**Solution:** Maintain two DATABASE_URL values for different contexts
|
||||
|
||||
### Container won't restart after settings change
|
||||
**Cause:** Restart watcher not running
|
||||
**Solution:**
|
||||
```bash
|
||||
sudo systemctl status trading-bot-restart-watcher
|
||||
sudo systemctl start trading-bot-restart-watcher
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Safety Guidelines
|
||||
|
||||
1. **Start Small**: Use $10-50 positions first
|
||||
2. **Test Thoroughly**: Run all test scripts
|
||||
3. **Monitor Closely**: Watch first 10 auto-exits
|
||||
4. **Verify Fills**: Check Drift UI after exits
|
||||
5. **Scale Gradually**: Increase size weekly
|
||||
1. **Start Small**: Use $10-50 positions for first 20 trades
|
||||
2. **Test Thoroughly**:
|
||||
- Run with DRY_RUN=true first
|
||||
- Execute test trades from settings UI
|
||||
- Verify all exits work correctly
|
||||
3. **Monitor Closely**: Watch first 10 auto-exits in real-time
|
||||
4. **Verify Database**: Check that all trades are being saved correctly
|
||||
5. **Check Drift UI**: Confirm positions and orders match expectations
|
||||
6. **Scale Gradually**: Increase position size 2x per week maximum
|
||||
7. **Daily Review**: Check analytics page every day
|
||||
8. **Backup Database**: Export PostgreSQL data weekly
|
||||
|
||||
**Risk Warning:** Cryptocurrency trading involves substantial risk. This bot executes trades automatically. Start small and never risk more than you can afford to lose.
|
||||
|
||||
---
|
||||
|
||||
## Testing Commands
|
||||
|
||||
```bash
|
||||
# Local development
|
||||
npm run dev
|
||||
|
||||
# Build production
|
||||
npm run build && npm start
|
||||
|
||||
# Docker build and restart
|
||||
docker compose build trading-bot
|
||||
docker compose up -d --force-recreate trading-bot
|
||||
docker logs -f trading-bot-v4
|
||||
|
||||
# Test trade from UI
|
||||
# Go to http://localhost:3001/settings
|
||||
# Click "Test LONG" or "Test SHORT"
|
||||
|
||||
# Test trade from terminal
|
||||
./send_trade.sh LONG SOL-PERP 15
|
||||
|
||||
# Check current positions
|
||||
curl -s -X GET http://localhost:3001/api/trading/positions \
|
||||
-H "Authorization: Bearer YOUR_API_SECRET_KEY" | jq
|
||||
|
||||
# Test exit order placement
|
||||
./test-exit-orders.sh
|
||||
|
||||
# Database operations
|
||||
npx prisma generate
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/trading_bot_v4" npx prisma migrate dev
|
||||
docker exec trading-bot-postgres psql -U postgres -d trading_bot_v4 -c "SELECT * FROM \"Trade\" ORDER BY \"createdAt\" DESC LIMIT 5;"
|
||||
|
||||
# Check logs
|
||||
docker compose logs --tail=100 trading-bot
|
||||
docker compose logs -f trading-bot | grep "Position Manager"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| `README.md` | Complete system overview (this file) |
|
||||
| `docs/SETUP.md` | Detailed setup instructions |
|
||||
| `docs/DOCKER.md` | Docker deployment guide |
|
||||
| `docs/TESTING.md` | Comprehensive testing guide |
|
||||
| `docs/TELEGRAM_BOT_README.md` | Telegram bot setup |
|
||||
| `docs/N8N_WORKFLOW_SETUP.md` | n8n workflow configuration |
|
||||
| `docs/PHASE_2_COMPLETE.md` | Phase 2 features and architecture |
|
||||
| `docs/QUICKREF_PHASE2.md` | Quick reference guide |
|
||||
| `.github/copilot-instructions.md` | AI agent instructions (architecture) |
|
||||
|
||||
---
|
||||
|
||||
@@ -379,11 +898,38 @@ TP2: $142.10 (+1.5%)
|
||||
|
||||
- **Drift Protocol**: https://drift.trade
|
||||
- **Drift Docs**: https://docs.drift.trade
|
||||
- **Drift SDK**: https://github.com/drift-labs/protocol-v2
|
||||
- **Pyth Network**: https://pyth.network
|
||||
- **Solana RPC**: https://helius.dev
|
||||
- **Solana RPC**: https://helius.dev (recommended)
|
||||
- **Next.js**: https://nextjs.org
|
||||
- **Prisma ORM**: https://prisma.io
|
||||
|
||||
---
|
||||
|
||||
**Ready to trade autonomously? Read `QUICKREF_PHASE2.md` to get started! 🚀**
|
||||
## Development Notes
|
||||
|
||||
*Start small, monitor closely, scale gradually!*
|
||||
### Key Patterns to Follow
|
||||
1. **Singleton Services**: Always use getter functions, never instantiate directly
|
||||
2. **Configuration**: Always use `getMergedConfig()`, never read env vars directly
|
||||
3. **Database Errors**: Wrap in try/catch, don't fail trades on DB errors
|
||||
4. **Price Calculations**: Direction matters - long vs short use opposite math
|
||||
5. **Reduce-Only Orders**: All TP/SL orders MUST have `reduceOnly: true`
|
||||
6. **Symbol Normalization**: Always use `normalizeTradingViewSymbol()`
|
||||
|
||||
### Adding New Features
|
||||
1. **New Config**: Update DEFAULT_TRADING_CONFIG + getConfigFromEnv() + .env
|
||||
2. **New Database Fields**: Update schema.prisma → migrate → regenerate → rebuild Docker
|
||||
3. **New API Endpoint**: Follow auth pattern, use getMergedConfig(), init services
|
||||
4. **Order Logic Changes**: Test with DRY_RUN=true first, use small positions
|
||||
|
||||
### Recent Bug Fixes (Oct 2024)
|
||||
- ✅ TP2 runner calculation: Now uses remaining position after TP1
|
||||
- ✅ Race condition: Exit orders now placed BEFORE monitoring starts
|
||||
- ✅ Order cancellation: removeTrade() properly cancels orphaned orders
|
||||
- ✅ Dynamic SL: Breakeven at +0.5%, profit lock at +1.2%
|
||||
|
||||
---
|
||||
|
||||
**Ready to trade autonomously? Start with `docs/SETUP.md` and test with DRY_RUN=true! 🚀**
|
||||
|
||||
*Dual-layer safety. Complete audit trail. Built for reliability.*
|
||||
|
||||
417
app/analytics/optimization/page.tsx
Normal file
417
app/analytics/optimization/page.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface TPSLAnalysis {
|
||||
success: boolean
|
||||
analysis?: {
|
||||
totalTrades: number
|
||||
winningTrades: number
|
||||
losingTrades: number
|
||||
winRate: number
|
||||
avgWin: number
|
||||
avgLoss: number
|
||||
profitFactor: number
|
||||
|
||||
maeAnalysis: {
|
||||
avgMAE: number
|
||||
medianMAE: number
|
||||
percentile25MAE: number
|
||||
percentile75MAE: number
|
||||
worstMAE: number
|
||||
}
|
||||
|
||||
mfeAnalysis: {
|
||||
avgMFE: number
|
||||
medianMFE: number
|
||||
percentile25MFE: number
|
||||
percentile75MFE: number
|
||||
bestMFE: number
|
||||
}
|
||||
|
||||
currentLevels: {
|
||||
tp1Percent: number
|
||||
tp2Percent: number
|
||||
slPercent: number
|
||||
tp1HitRate: number
|
||||
tp2HitRate: number
|
||||
slHitRate: number
|
||||
moneyLeftOnTable: number
|
||||
}
|
||||
|
||||
recommendations: {
|
||||
optimalTP1: number
|
||||
optimalTP2: number
|
||||
optimalSL: number
|
||||
|
||||
reasoning: {
|
||||
tp1: string
|
||||
tp2: string
|
||||
sl: string
|
||||
}
|
||||
|
||||
projectedImpact: {
|
||||
expectedWinRateChange: number
|
||||
expectedProfitFactorChange: number
|
||||
estimatedProfitImprovement: number
|
||||
}
|
||||
}
|
||||
|
||||
tradesByOutcome: {
|
||||
tp1Exits: number
|
||||
tp2Exits: number
|
||||
slExits: number
|
||||
manualExits: number
|
||||
}
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
||||
export default function OptimizationPage() {
|
||||
const [analysis, setAnalysis] = useState<TPSLAnalysis | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalysis()
|
||||
}, [])
|
||||
|
||||
const fetchAnalysis = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const response = await fetch('/api/analytics/tp-sl-optimization')
|
||||
const data = await response.json()
|
||||
|
||||
setAnalysis(data)
|
||||
|
||||
if (!data.success) {
|
||||
setError(data.error || 'Failed to load analysis')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to fetch analytics: ' + (err as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
|
||||
<Header />
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mb-4"></div>
|
||||
<p className="text-gray-400">Loading optimization analysis...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !analysis?.success) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
|
||||
<Header />
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="bg-yellow-900/20 border border-yellow-600 rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-yellow-400 mb-2">⚠️ Insufficient Data</h2>
|
||||
<p className="text-gray-300 mb-4">{error || analysis?.error}</p>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
Need at least 10 closed trades with MAE/MFE tracking data.
|
||||
The next trades you take will automatically track this data.
|
||||
</p>
|
||||
<button
|
||||
onClick={fetchAnalysis}
|
||||
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-lg font-semibold transition-colors"
|
||||
>
|
||||
🔄 Refresh Analytics
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const data = analysis.analysis!
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900">
|
||||
<Header />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header with Refresh */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">💡 TP/SL Optimization</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">Based on {data.totalTrades} trades with MAE/MFE data</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchAnalysis}
|
||||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-white"
|
||||
>
|
||||
🔄 Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Overview Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard title="Total Trades" value={data.totalTrades.toString()} />
|
||||
<StatCard
|
||||
title="Win Rate"
|
||||
value={data.winRate.toFixed(1) + '%'}
|
||||
valueColor="text-green-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="Profit Factor"
|
||||
value={data.profitFactor.toFixed(2)}
|
||||
valueColor="text-blue-400"
|
||||
/>
|
||||
<StatCard
|
||||
title="Money Left on Table"
|
||||
value={'$' + data.currentLevels.moneyLeftOnTable.toFixed(2)}
|
||||
valueColor="text-yellow-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MAE/MFE Analysis */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm rounded-xl p-6 border border-gray-700">
|
||||
<h3 className="text-xl font-semibold mb-4 text-green-400">
|
||||
📈 Maximum Favorable Excursion (MFE)
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<MetricRow label="Average" value={data.mfeAnalysis.avgMFE.toFixed(2) + '%'} />
|
||||
<MetricRow label="Median" value={data.mfeAnalysis.medianMFE.toFixed(2) + '%'} />
|
||||
<MetricRow label="25th Percentile" value={data.mfeAnalysis.percentile25MFE.toFixed(2) + '%'} />
|
||||
<MetricRow label="75th Percentile" value={data.mfeAnalysis.percentile75MFE.toFixed(2) + '%'} />
|
||||
<div className="border-t border-gray-700 pt-3">
|
||||
<MetricRow
|
||||
label="Best"
|
||||
value={data.mfeAnalysis.bestMFE.toFixed(2) + '%'}
|
||||
valueColor="text-green-400 font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm rounded-xl p-6 border border-gray-700">
|
||||
<h3 className="text-xl font-semibold mb-4 text-red-400">
|
||||
📉 Maximum Adverse Excursion (MAE)
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<MetricRow label="Average" value={data.maeAnalysis.avgMAE.toFixed(2) + '%'} />
|
||||
<MetricRow label="Median" value={data.maeAnalysis.medianMAE.toFixed(2) + '%'} />
|
||||
<MetricRow label="25th Percentile" value={data.maeAnalysis.percentile25MAE.toFixed(2) + '%'} />
|
||||
<MetricRow label="75th Percentile" value={data.maeAnalysis.percentile75MAE.toFixed(2) + '%'} />
|
||||
<div className="border-t border-gray-700 pt-3">
|
||||
<MetricRow
|
||||
label="Worst"
|
||||
value={data.maeAnalysis.worstMAE.toFixed(2) + '%'}
|
||||
valueColor="text-red-400 font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Configuration Performance */}
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm rounded-xl p-6 border border-gray-700 mb-8">
|
||||
<h3 className="text-xl font-semibold mb-6 text-white">🎯 Current Configuration Performance</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<HitRateBar
|
||||
label={'TP1: ' + data.currentLevels.tp1Percent + '%'}
|
||||
hitRate={data.currentLevels.tp1HitRate}
|
||||
exits={data.tradesByOutcome.tp1Exits}
|
||||
color="bg-green-500"
|
||||
/>
|
||||
<HitRateBar
|
||||
label={'TP2: ' + data.currentLevels.tp2Percent + '%'}
|
||||
hitRate={data.currentLevels.tp2HitRate}
|
||||
exits={data.tradesByOutcome.tp2Exits}
|
||||
color="bg-blue-500"
|
||||
/>
|
||||
<HitRateBar
|
||||
label={'SL: ' + data.currentLevels.slPercent + '%'}
|
||||
hitRate={data.currentLevels.slHitRate}
|
||||
exits={data.tradesByOutcome.slExits}
|
||||
color="bg-red-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
<div className="bg-gradient-to-r from-blue-900/30 to-purple-900/30 border border-blue-700 rounded-xl p-6 mb-8">
|
||||
<h2 className="text-2xl font-bold mb-6 text-white">💡 Optimization Recommendations</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<RecommendationCard
|
||||
label="Optimal TP1"
|
||||
value={data.recommendations.optimalTP1.toFixed(2) + '%'}
|
||||
current={data.currentLevels.tp1Percent + '%'}
|
||||
color="text-green-400"
|
||||
/>
|
||||
<RecommendationCard
|
||||
label="Optimal TP2"
|
||||
value={data.recommendations.optimalTP2.toFixed(2) + '%'}
|
||||
current={data.currentLevels.tp2Percent + '%'}
|
||||
color="text-blue-400"
|
||||
/>
|
||||
<RecommendationCard
|
||||
label="Optimal SL"
|
||||
value={data.recommendations.optimalSL.toFixed(2) + '%'}
|
||||
current={data.currentLevels.slPercent + '%'}
|
||||
color="text-red-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reasoning */}
|
||||
<div className="space-y-3 mb-6">
|
||||
<ReasoningCard
|
||||
label="TP1 Reasoning"
|
||||
text={data.recommendations.reasoning.tp1}
|
||||
color="border-green-700 bg-green-900/20"
|
||||
/>
|
||||
<ReasoningCard
|
||||
label="TP2 Reasoning"
|
||||
text={data.recommendations.reasoning.tp2}
|
||||
color="border-blue-700 bg-blue-900/20"
|
||||
/>
|
||||
<ReasoningCard
|
||||
label="SL Reasoning"
|
||||
text={data.recommendations.reasoning.sl}
|
||||
color="border-red-700 bg-red-900/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Projected Impact */}
|
||||
<div className="bg-gray-800/50 rounded-lg p-6 border border-gray-700">
|
||||
<h3 className="text-lg font-semibold mb-4 text-white">📊 Projected Impact</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<ImpactMetric
|
||||
label="Win Rate Change"
|
||||
value={data.recommendations.projectedImpact.expectedWinRateChange.toFixed(1) + '%'}
|
||||
positive={data.recommendations.projectedImpact.expectedWinRateChange >= 0}
|
||||
/>
|
||||
<ImpactMetric
|
||||
label="Profit Factor Change"
|
||||
value={data.recommendations.projectedImpact.expectedProfitFactorChange.toFixed(2)}
|
||||
positive={data.recommendations.projectedImpact.expectedProfitFactorChange >= 0}
|
||||
/>
|
||||
<ImpactMetric
|
||||
label="Profit Improvement"
|
||||
value={data.recommendations.projectedImpact.estimatedProfitImprovement.toFixed(1) + '%'}
|
||||
positive={data.recommendations.projectedImpact.estimatedProfitImprovement >= 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm rounded-xl p-6 border border-gray-700 text-center">
|
||||
<p className="text-gray-400 mb-4">
|
||||
Ready to apply these optimized levels? Update your configuration in Settings.
|
||||
</p>
|
||||
<Link
|
||||
href="/settings"
|
||||
className="inline-block px-8 py-3 bg-blue-600 hover:bg-blue-700 rounded-lg font-semibold transition-colors text-white"
|
||||
>
|
||||
⚙️ Go to Settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Component helpers
|
||||
function Header() {
|
||||
return (
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm border-b border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href="/analytics" className="text-gray-400 hover:text-white transition">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">🎯 TP/SL Optimization</h1>
|
||||
<p className="text-sm text-gray-400">Data-driven recommendations for optimal exit levels</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ title, value, valueColor = 'text-white' }: { title: string, value: string, valueColor?: string }) {
|
||||
return (
|
||||
<div className="bg-gray-800/50 backdrop-blur-sm rounded-xl p-6 border border-gray-700">
|
||||
<div className="text-sm text-gray-400 mb-1">{title}</div>
|
||||
<div className={'text-2xl font-bold ' + valueColor}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricRow({ label, value, valueColor = 'text-white' }: { label: string, value: string, valueColor?: string }) {
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">{label}:</span>
|
||||
<span className={'font-semibold ' + valueColor}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HitRateBar({ label, hitRate, exits, color }: { label: string, hitRate: number, exits: number, color: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-2">{label}</div>
|
||||
<div className="bg-gray-700 rounded-full h-4 overflow-hidden">
|
||||
<div
|
||||
className={color + ' h-full transition-all duration-500'}
|
||||
style={{ width: hitRate + '%' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<div className="text-xs text-gray-400">Hit Rate: {hitRate.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-500">{exits} exits</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RecommendationCard({ label, value, current, color }: { label: string, value: string, current: string, color: string }) {
|
||||
return (
|
||||
<div className="bg-gray-800/50 rounded-lg p-4 border border-gray-700">
|
||||
<div className="text-sm text-gray-400 mb-1">{label}</div>
|
||||
<div className={'text-3xl font-bold ' + color}>{value}</div>
|
||||
<div className="text-xs text-gray-400 mt-2">Current: {current}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReasoningCard({ label, text, color }: { label: string, text: string, color: string }) {
|
||||
return (
|
||||
<div className={'rounded-lg p-4 border ' + color}>
|
||||
<div className="font-semibold text-white mb-1">{label}</div>
|
||||
<div className="text-sm text-gray-300">{text}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImpactMetric({ label, value, positive }: { label: string, value: string, positive: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-sm text-gray-400 mb-1">{label}</div>
|
||||
<div className={'text-2xl font-bold ' + (positive ? 'text-green-400' : 'text-red-400')}>
|
||||
{positive ? '+' : ''}{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -106,18 +106,27 @@ export default function AnalyticsPage() {
|
||||
</div>
|
||||
|
||||
{/* Time Period Selector */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-400">Period:</span>
|
||||
<select
|
||||
value={selectedDays}
|
||||
onChange={(e) => setSelectedDays(Number(e.target.value))}
|
||||
className="bg-gray-700 text-white rounded-lg px-4 py-2 border border-gray-600 focus:border-blue-500 focus:outline-none"
|
||||
<div className="flex items-center space-x-4">
|
||||
<a
|
||||
href="/analytics/optimization"
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white font-semibold transition-colors"
|
||||
>
|
||||
<option value={7}>7 days</option>
|
||||
<option value={30}>30 days</option>
|
||||
<option value={90}>90 days</option>
|
||||
<option value={365}>1 year</option>
|
||||
</select>
|
||||
🎯 TP/SL Optimization
|
||||
</a>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-400">Period:</span>
|
||||
<select
|
||||
value={selectedDays}
|
||||
onChange={(e) => setSelectedDays(Number(e.target.value))}
|
||||
className="bg-gray-700 text-white rounded-lg px-4 py-2 border border-gray-600 focus:border-blue-500 focus:outline-none"
|
||||
>
|
||||
<option value={7}>7 days</option>
|
||||
<option value={30}>30 days</option>
|
||||
<option value={90}>90 days</option>
|
||||
<option value={365}>1 year</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
319
app/api/analytics/tp-sl-optimization/route.ts
Normal file
319
app/api/analytics/tp-sl-optimization/route.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* TP/SL Optimization API Endpoint
|
||||
*
|
||||
* Analyzes historical trades using MAE/MFE data to recommend optimal TP/SL levels
|
||||
* GET /api/analytics/tp-sl-optimization
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getPrismaClient } from '@/lib/database/trades'
|
||||
|
||||
export interface TPSLOptimizationResponse {
|
||||
success: boolean
|
||||
analysis?: {
|
||||
totalTrades: number
|
||||
winningTrades: number
|
||||
losingTrades: number
|
||||
winRate: number
|
||||
avgWin: number
|
||||
avgLoss: number
|
||||
profitFactor: number
|
||||
|
||||
// MAE/MFE Analysis
|
||||
maeAnalysis: {
|
||||
avgMAE: number
|
||||
medianMAE: number
|
||||
percentile25MAE: number
|
||||
percentile75MAE: number
|
||||
worstMAE: number
|
||||
}
|
||||
|
||||
mfeAnalysis: {
|
||||
avgMFE: number
|
||||
medianMFE: number
|
||||
percentile25MFE: number
|
||||
percentile75MFE: number
|
||||
bestMFE: number
|
||||
}
|
||||
|
||||
// Current Configuration Performance
|
||||
currentLevels: {
|
||||
tp1Percent: number
|
||||
tp2Percent: number
|
||||
slPercent: number
|
||||
tp1HitRate: number
|
||||
tp2HitRate: number
|
||||
slHitRate: number
|
||||
moneyLeftOnTable: number // Sum of (MFE - realized P&L) for winning trades
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
recommendations: {
|
||||
optimalTP1: number // 50% of avg MFE
|
||||
optimalTP2: number // 80% of avg MFE
|
||||
optimalSL: number // 70% of avg MAE (tighter to catch losers early)
|
||||
|
||||
reasoning: {
|
||||
tp1: string
|
||||
tp2: string
|
||||
sl: string
|
||||
}
|
||||
|
||||
projectedImpact: {
|
||||
expectedWinRateChange: number
|
||||
expectedProfitFactorChange: number
|
||||
estimatedProfitImprovement: number // % improvement in total P&L
|
||||
}
|
||||
}
|
||||
|
||||
// Detailed Trade Stats
|
||||
tradesByOutcome: {
|
||||
tp1Exits: number
|
||||
tp2Exits: number
|
||||
slExits: number
|
||||
manualExits: number
|
||||
}
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse<TPSLOptimizationResponse>> {
|
||||
try {
|
||||
const prisma = getPrismaClient()
|
||||
|
||||
// Get all closed trades with MAE/MFE data
|
||||
const trades = await prisma.trade.findMany({
|
||||
where: {
|
||||
status: 'closed',
|
||||
maxFavorableExcursion: { not: null },
|
||||
maxAdverseExcursion: { not: null },
|
||||
},
|
||||
orderBy: {
|
||||
entryTime: 'desc',
|
||||
},
|
||||
})
|
||||
|
||||
if (trades.length < 10) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: `Insufficient data: Only ${trades.length} trades found. Need at least 10 trades with MAE/MFE data for meaningful analysis.`,
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`📊 Analyzing ${trades.length} trades for TP/SL optimization`)
|
||||
|
||||
// Separate winning and losing trades
|
||||
const winningTrades = trades.filter(t => (t.realizedPnL || 0) > 0)
|
||||
const losingTrades = trades.filter(t => (t.realizedPnL || 0) <= 0)
|
||||
|
||||
// Calculate basic stats
|
||||
const totalPnL = trades.reduce((sum, t) => sum + (t.realizedPnL || 0), 0)
|
||||
const avgWin = winningTrades.length > 0
|
||||
? winningTrades.reduce((sum, t) => sum + (t.realizedPnL || 0), 0) / winningTrades.length
|
||||
: 0
|
||||
const avgLoss = losingTrades.length > 0
|
||||
? Math.abs(losingTrades.reduce((sum, t) => sum + (t.realizedPnL || 0), 0) / losingTrades.length)
|
||||
: 0
|
||||
const winRate = (winningTrades.length / trades.length) * 100
|
||||
const profitFactor = avgLoss > 0 ? (avgWin * winningTrades.length) / (avgLoss * losingTrades.length) : 0
|
||||
|
||||
// MAE Analysis (how far price moved against us)
|
||||
const maeValues = trades
|
||||
.map(t => t.maxAdverseExcursion!)
|
||||
.filter(v => v !== null && v !== undefined)
|
||||
.sort((a, b) => a - b)
|
||||
|
||||
const avgMAE = maeValues.reduce((sum, v) => sum + v, 0) / maeValues.length
|
||||
const medianMAE = maeValues[Math.floor(maeValues.length / 2)]
|
||||
const percentile25MAE = maeValues[Math.floor(maeValues.length * 0.25)]
|
||||
const percentile75MAE = maeValues[Math.floor(maeValues.length * 0.75)]
|
||||
const worstMAE = Math.min(...maeValues)
|
||||
|
||||
// MFE Analysis (how far price moved in our favor)
|
||||
const mfeValues = trades
|
||||
.map(t => t.maxFavorableExcursion!)
|
||||
.filter(v => v !== null && v !== undefined)
|
||||
.sort((a, b) => b - a)
|
||||
|
||||
const avgMFE = mfeValues.reduce((sum, v) => sum + v, 0) / mfeValues.length
|
||||
const medianMFE = mfeValues[Math.floor(mfeValues.length / 2)]
|
||||
const percentile25MFE = mfeValues[Math.floor(mfeValues.length * 0.75)] // Reverse for MFE
|
||||
const percentile75MFE = mfeValues[Math.floor(mfeValues.length * 0.25)]
|
||||
const bestMFE = Math.max(...mfeValues)
|
||||
|
||||
// Current configuration analysis (extract from first trade's config snapshot)
|
||||
const sampleConfig: any = trades[0]?.configSnapshot || {}
|
||||
const currentTP1 = sampleConfig.takeProfit1Percent || 0.4
|
||||
const currentTP2 = sampleConfig.takeProfit2Percent || 0.7
|
||||
const currentSL = sampleConfig.stopLossPercent || -1.1
|
||||
|
||||
// Calculate hit rates for current levels
|
||||
const tp1Hits = trades.filter(t => {
|
||||
const mfe = t.maxFavorableExcursion || 0
|
||||
return mfe >= currentTP1
|
||||
}).length
|
||||
|
||||
const tp2Hits = trades.filter(t => {
|
||||
const mfe = t.maxFavorableExcursion || 0
|
||||
return mfe >= currentTP2
|
||||
}).length
|
||||
|
||||
const slHits = trades.filter(t => {
|
||||
const mae = t.maxAdverseExcursion || 0
|
||||
return mae <= currentSL
|
||||
}).length
|
||||
|
||||
const tp1HitRate = (tp1Hits / trades.length) * 100
|
||||
const tp2HitRate = (tp2Hits / trades.length) * 100
|
||||
const slHitRate = (slHits / trades.length) * 100
|
||||
|
||||
// Calculate "money left on table" - how much profit we didn't capture
|
||||
const moneyLeftOnTable = winningTrades.reduce((sum, t) => {
|
||||
const mfe = t.maxFavorableExcursion || 0
|
||||
const realizedPct = ((t.realizedPnL || 0) / t.positionSizeUSD) * 100
|
||||
const leftOnTable = Math.max(0, mfe - realizedPct)
|
||||
return sum + (leftOnTable * t.positionSizeUSD / 100)
|
||||
}, 0)
|
||||
|
||||
// Calculate optimal levels
|
||||
const optimalTP1 = avgMFE * 0.5 // Capture 50% of avg move
|
||||
const optimalTP2 = avgMFE * 0.8 // Capture 80% of avg move
|
||||
const optimalSL = avgMAE * 0.7 // Exit at 70% of avg adverse move (tighter to minimize losses)
|
||||
|
||||
// Trade outcome breakdown
|
||||
const tp1Exits = trades.filter(t => t.exitReason === 'TP1').length
|
||||
const tp2Exits = trades.filter(t => t.exitReason === 'TP2').length
|
||||
const slExits = trades.filter(t =>
|
||||
t.exitReason === 'SL' || t.exitReason === 'SOFT_SL' || t.exitReason === 'HARD_SL'
|
||||
).length
|
||||
const manualExits = trades.filter(t =>
|
||||
t.exitReason === 'manual' || t.exitReason === 'emergency'
|
||||
).length
|
||||
|
||||
// Projected impact calculation
|
||||
// Simulate what would have happened with optimal levels
|
||||
let projectedWins = 0
|
||||
let projectedLosses = 0
|
||||
let projectedTotalPnL = 0
|
||||
|
||||
trades.forEach(t => {
|
||||
const mfe = t.maxFavorableExcursion || 0
|
||||
const mae = t.maxAdverseExcursion || 0
|
||||
|
||||
// Would SL have been hit first with optimal level?
|
||||
if (mae <= optimalSL) {
|
||||
projectedLosses++
|
||||
projectedTotalPnL += optimalSL * t.positionSizeUSD / 100
|
||||
}
|
||||
// Would TP1 have been hit?
|
||||
else if (mfe >= optimalTP1) {
|
||||
projectedWins++
|
||||
// Assume 50% exit at TP1, 50% continues to TP2 or SL
|
||||
const tp1PnL = optimalTP1 * t.positionSizeUSD * 0.5 / 100
|
||||
|
||||
if (mfe >= optimalTP2) {
|
||||
const tp2PnL = optimalTP2 * t.positionSizeUSD * 0.5 / 100
|
||||
projectedTotalPnL += tp1PnL + tp2PnL
|
||||
} else {
|
||||
// TP2 not hit, remaining 50% exits at breakeven or small profit
|
||||
projectedTotalPnL += tp1PnL
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const projectedWinRate = (projectedWins / trades.length) * 100
|
||||
const expectedWinRateChange = projectedWinRate - winRate
|
||||
|
||||
const projectedProfitFactor = projectedLosses > 0
|
||||
? (projectedWins * avgWin) / (projectedLosses * avgLoss)
|
||||
: 0
|
||||
const expectedProfitFactorChange = projectedProfitFactor - profitFactor
|
||||
|
||||
const estimatedProfitImprovement = totalPnL > 0
|
||||
? ((projectedTotalPnL - totalPnL) / totalPnL) * 100
|
||||
: 0
|
||||
|
||||
// Build response
|
||||
const analysis: TPSLOptimizationResponse = {
|
||||
success: true,
|
||||
analysis: {
|
||||
totalTrades: trades.length,
|
||||
winningTrades: winningTrades.length,
|
||||
losingTrades: losingTrades.length,
|
||||
winRate,
|
||||
avgWin,
|
||||
avgLoss,
|
||||
profitFactor,
|
||||
|
||||
maeAnalysis: {
|
||||
avgMAE,
|
||||
medianMAE,
|
||||
percentile25MAE,
|
||||
percentile75MAE,
|
||||
worstMAE,
|
||||
},
|
||||
|
||||
mfeAnalysis: {
|
||||
avgMFE,
|
||||
medianMFE,
|
||||
percentile25MFE,
|
||||
percentile75MFE,
|
||||
bestMFE,
|
||||
},
|
||||
|
||||
currentLevels: {
|
||||
tp1Percent: currentTP1,
|
||||
tp2Percent: currentTP2,
|
||||
slPercent: currentSL,
|
||||
tp1HitRate,
|
||||
tp2HitRate,
|
||||
slHitRate,
|
||||
moneyLeftOnTable,
|
||||
},
|
||||
|
||||
recommendations: {
|
||||
optimalTP1,
|
||||
optimalTP2,
|
||||
optimalSL,
|
||||
|
||||
reasoning: {
|
||||
tp1: `Set at ${optimalTP1.toFixed(2)}% (50% of avg MFE ${avgMFE.toFixed(2)}%). This captures early profits while letting winners run. Current hit rate: ${tp1HitRate.toFixed(1)}%`,
|
||||
tp2: `Set at ${optimalTP2.toFixed(2)}% (80% of avg MFE ${avgMFE.toFixed(2)}%). This captures most of the move before reversal. Current hit rate: ${tp2HitRate.toFixed(1)}%`,
|
||||
sl: `Set at ${optimalSL.toFixed(2)}% (70% of avg MAE ${avgMAE.toFixed(2)}%). Tighter stop to minimize losses on bad trades. Current hit rate: ${slHitRate.toFixed(1)}%`,
|
||||
},
|
||||
|
||||
projectedImpact: {
|
||||
expectedWinRateChange,
|
||||
expectedProfitFactorChange,
|
||||
estimatedProfitImprovement,
|
||||
},
|
||||
},
|
||||
|
||||
tradesByOutcome: {
|
||||
tp1Exits,
|
||||
tp2Exits,
|
||||
slExits,
|
||||
manualExits,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
console.log('✅ TP/SL optimization analysis complete')
|
||||
console.log(' Current: TP1=' + currentTP1 + '% TP2=' + currentTP2 + '% SL=' + currentSL + '%')
|
||||
console.log(' Optimal: TP1=' + optimalTP1.toFixed(2) + '% TP2=' + optimalTP2.toFixed(2) + '% SL=' + optimalSL.toFixed(2) + '%')
|
||||
console.log(' Projected improvement: ' + estimatedProfitImprovement.toFixed(1) + '%')
|
||||
|
||||
return NextResponse.json(analysis)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ TP/SL optimization error:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to analyze trades: ' + (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMergedConfig } from '@/config/trading'
|
||||
import { getInitializedPositionManager } from '@/lib/trading/position-manager'
|
||||
import { getLastTradeTime, getTradesInLastHour, getTodayPnL } from '@/lib/database/trades'
|
||||
|
||||
export interface RiskCheckRequest {
|
||||
symbol: string
|
||||
@@ -41,23 +43,107 @@ export async function POST(request: NextRequest): Promise<NextResponse<RiskCheck
|
||||
|
||||
const config = getMergedConfig()
|
||||
|
||||
// TODO: Implement actual risk checks:
|
||||
// 1. Check daily drawdown
|
||||
// Check for existing positions on the same symbol
|
||||
const positionManager = await getInitializedPositionManager()
|
||||
const existingTrades = Array.from(positionManager.getActiveTrades().values())
|
||||
const existingPosition = existingTrades.find(trade => trade.symbol === body.symbol)
|
||||
|
||||
if (existingPosition) {
|
||||
// Check if it's the SAME direction (duplicate - block it)
|
||||
if (existingPosition.direction === body.direction) {
|
||||
console.log('🚫 Risk check BLOCKED: Duplicate position (same direction)', {
|
||||
symbol: body.symbol,
|
||||
existingDirection: existingPosition.direction,
|
||||
requestedDirection: body.direction,
|
||||
existingEntry: existingPosition.entryPrice,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
allowed: false,
|
||||
reason: 'Duplicate position',
|
||||
details: `Already have ${existingPosition.direction} position on ${body.symbol} (entry: $${existingPosition.entryPrice})`,
|
||||
})
|
||||
}
|
||||
|
||||
// OPPOSITE direction - this is a signal flip/reversal (ALLOW IT)
|
||||
console.log('🔄 Risk check: Signal flip detected', {
|
||||
symbol: body.symbol,
|
||||
existingDirection: existingPosition.direction,
|
||||
newDirection: body.direction,
|
||||
note: 'Will close existing and open opposite',
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
allowed: true,
|
||||
reason: 'Signal flip',
|
||||
details: `Signal reversed from ${existingPosition.direction} to ${body.direction} - will flip position`,
|
||||
})
|
||||
}
|
||||
|
||||
// 1. Check daily drawdown limit
|
||||
const todayPnL = await getTodayPnL()
|
||||
if (todayPnL < config.maxDailyDrawdown) {
|
||||
console.log('🚫 Risk check BLOCKED: Daily drawdown limit reached', {
|
||||
todayPnL: todayPnL.toFixed(2),
|
||||
maxDrawdown: config.maxDailyDrawdown,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
allowed: false,
|
||||
reason: 'Daily drawdown limit',
|
||||
details: `Today's P&L ($${todayPnL.toFixed(2)}) has reached max drawdown limit ($${config.maxDailyDrawdown})`,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Check trades per hour limit
|
||||
const tradesInLastHour = await getTradesInLastHour()
|
||||
if (tradesInLastHour >= config.maxTradesPerHour) {
|
||||
console.log('🚫 Risk check BLOCKED: Hourly trade limit reached', {
|
||||
tradesInLastHour,
|
||||
maxTradesPerHour: config.maxTradesPerHour,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
allowed: false,
|
||||
reason: 'Hourly trade limit',
|
||||
details: `Already placed ${tradesInLastHour} trades in the last hour (max: ${config.maxTradesPerHour})`,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Check cooldown period
|
||||
// 4. Check account health
|
||||
// 5. Check existing positions
|
||||
const lastTradeTime = await getLastTradeTime()
|
||||
if (lastTradeTime && config.minTimeBetweenTrades > 0) {
|
||||
const timeSinceLastTrade = Date.now() - lastTradeTime.getTime()
|
||||
const cooldownMs = config.minTimeBetweenTrades * 60 * 1000 // Convert minutes to milliseconds
|
||||
|
||||
if (timeSinceLastTrade < cooldownMs) {
|
||||
const remainingMs = cooldownMs - timeSinceLastTrade
|
||||
const remainingMinutes = Math.ceil(remainingMs / 60000)
|
||||
|
||||
console.log('🚫 Risk check BLOCKED: Cooldown period active', {
|
||||
lastTradeTime: lastTradeTime.toISOString(),
|
||||
timeSinceLastTradeMs: timeSinceLastTrade,
|
||||
cooldownMs,
|
||||
remainingMinutes,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
allowed: false,
|
||||
reason: 'Cooldown period',
|
||||
details: `Must wait ${remainingMinutes} more minute(s) before next trade (cooldown: ${config.minTimeBetweenTrades} min)`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// For now, always allow (will implement in next phase)
|
||||
const allowed = true
|
||||
const reason = allowed ? undefined : 'Risk limit exceeded'
|
||||
|
||||
console.log(`✅ Risk check: ${allowed ? 'PASSED' : 'BLOCKED'}`)
|
||||
console.log(`✅ Risk check PASSED: All checks passed`, {
|
||||
todayPnL: todayPnL.toFixed(2),
|
||||
tradesLastHour: tradesInLastHour,
|
||||
cooldownPassed: lastTradeTime ? 'yes' : 'no previous trades',
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
allowed,
|
||||
reason,
|
||||
details: allowed ? 'All risk checks passed' : undefined,
|
||||
allowed: true,
|
||||
details: 'All risk checks passed',
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -94,20 +94,74 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
{
|
||||
success: false,
|
||||
error: 'Insufficient collateral',
|
||||
message: `Free collateral: $${health.freeCollateral.toFixed(2)}`,
|
||||
message: 'Free collateral: $' + health.freeCollateral.toFixed(2),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// AUTO-FLIP: Check for existing opposite direction position
|
||||
const positionManager = await getInitializedPositionManager()
|
||||
const existingTrades = Array.from(positionManager.getActiveTrades().values())
|
||||
const oppositePosition = existingTrades.find(
|
||||
trade => trade.symbol === driftSymbol && trade.direction !== body.direction
|
||||
)
|
||||
|
||||
if (oppositePosition) {
|
||||
console.log(`🔄 Signal flip detected! Closing ${oppositePosition.direction} to open ${body.direction}`)
|
||||
|
||||
// Close opposite position
|
||||
const { closePosition } = await import('@/lib/drift/orders')
|
||||
const closeResult = await closePosition({
|
||||
symbol: driftSymbol,
|
||||
percentToClose: 100,
|
||||
slippageTolerance: config.slippageTolerance,
|
||||
})
|
||||
|
||||
if (!closeResult.success) {
|
||||
console.error('❌ Failed to close opposite position:', closeResult.error)
|
||||
// Continue anyway - we'll try to open the new position
|
||||
} else {
|
||||
console.log('✅ Closed ' + oppositePosition.direction + ' position at $' + closeResult.closePrice?.toFixed(4) + ' (P&L: $' + closeResult.realizedPnL?.toFixed(2) + ')')
|
||||
|
||||
// Position Manager will handle cleanup (including order cancellation)
|
||||
// The executeExit method already removes the trade and updates database
|
||||
}
|
||||
|
||||
// Small delay to ensure position is fully closed
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
|
||||
// Calculate position size with leverage
|
||||
const positionSizeUSD = config.positionSize * config.leverage
|
||||
|
||||
console.log(`💰 Opening ${body.direction} position:`)
|
||||
console.log(` Symbol: ${driftSymbol}`)
|
||||
console.log(` Base size: $${config.positionSize}`)
|
||||
console.log(` Leverage: ${config.leverage}x`)
|
||||
console.log(` Total position: $${positionSizeUSD}`)
|
||||
console.log('💰 Opening ' + body.direction + ' position:')
|
||||
console.log(' Symbol: ' + driftSymbol)
|
||||
console.log(' Base size: $' + config.positionSize)
|
||||
console.log(' Leverage: ' + config.leverage + 'x')
|
||||
console.log(' Total position: $' + positionSizeUSD)
|
||||
|
||||
// Capture market context BEFORE opening position
|
||||
const { getMarketConfig } = await import('@/config/trading')
|
||||
const marketConfig = getMarketConfig(driftSymbol)
|
||||
|
||||
let expectedEntryPrice: number | undefined
|
||||
let fundingRateAtEntry: number | undefined
|
||||
|
||||
try {
|
||||
// Get expected entry price from oracle
|
||||
expectedEntryPrice = await driftService.getOraclePrice(marketConfig.driftMarketIndex)
|
||||
console.log('📊 Expected entry price: $' + expectedEntryPrice.toFixed(4))
|
||||
|
||||
// Get funding rate
|
||||
fundingRateAtEntry = await driftService.getFundingRate(marketConfig.driftMarketIndex) || undefined
|
||||
if (fundingRateAtEntry) {
|
||||
console.log('💸 Funding rate: ' + (fundingRateAtEntry * 100).toFixed(4) + '%')
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Failed to capture market context:', error)
|
||||
// Don't fail the trade if market context capture fails
|
||||
}
|
||||
|
||||
// Open position
|
||||
const openResult = await openPosition({
|
||||
@@ -152,9 +206,9 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
config.hardStopPercent,
|
||||
body.direction
|
||||
)
|
||||
console.log('🛡️🛡️ Dual stop system enabled:')
|
||||
console.log(` Soft stop: $${softStopPrice.toFixed(4)} (${config.softStopPercent}%)`)
|
||||
console.log(` Hard stop: $${hardStopPrice.toFixed(4)} (${config.hardStopPercent}%)`)
|
||||
console.log('🛡️ Dual stop system enabled:')
|
||||
console.log(' Soft stop: $' + softStopPrice.toFixed(4) + ' (' + config.softStopPercent + '%)')
|
||||
console.log(' Hard stop: $' + hardStopPrice.toFixed(4) + ' (' + config.hardStopPercent + '%)')
|
||||
}
|
||||
|
||||
const tp1Price = calculatePrice(
|
||||
@@ -170,10 +224,10 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
)
|
||||
|
||||
console.log('📊 Trade targets:')
|
||||
console.log(` Entry: $${entryPrice.toFixed(4)}`)
|
||||
console.log(` SL: $${stopLossPrice.toFixed(4)} (${config.stopLossPercent}%)`)
|
||||
console.log(` TP1: $${tp1Price.toFixed(4)} (${config.takeProfit1Percent}%)`)
|
||||
console.log(` TP2: $${tp2Price.toFixed(4)} (${config.takeProfit2Percent}%)`)
|
||||
console.log(' Entry: $' + entryPrice.toFixed(4))
|
||||
console.log(' SL: $' + stopLossPrice.toFixed(4) + ' (' + config.stopLossPercent + '%)')
|
||||
console.log(' TP1: $' + tp1Price.toFixed(4) + ' (' + config.takeProfit1Percent + '%)')
|
||||
console.log(' TP2: $' + tp2Price.toFixed(4) + ' (' + config.takeProfit2Percent + '%)')
|
||||
|
||||
// Calculate emergency stop
|
||||
const emergencyStopPrice = calculatePrice(
|
||||
@@ -209,10 +263,46 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
priceCheckCount: 0,
|
||||
lastPrice: entryPrice,
|
||||
lastUpdateTime: Date.now(),
|
||||
maxFavorableExcursion: 0,
|
||||
maxAdverseExcursion: 0,
|
||||
maxFavorablePrice: entryPrice,
|
||||
maxAdversePrice: entryPrice,
|
||||
lastDbMetricsUpdate: Date.now(),
|
||||
}
|
||||
|
||||
// Add to position manager for monitoring
|
||||
const positionManager = await getInitializedPositionManager()
|
||||
// CRITICAL FIX: Place on-chain TP/SL orders BEFORE adding to Position Manager
|
||||
// This prevents race condition where Position Manager detects "external closure"
|
||||
// while orders are still being placed, leaving orphaned stop loss orders
|
||||
let exitOrderSignatures: string[] = []
|
||||
try {
|
||||
const exitRes = await placeExitOrders({
|
||||
symbol: driftSymbol,
|
||||
positionSizeUSD: positionSizeUSD,
|
||||
entryPrice: entryPrice,
|
||||
tp1Price,
|
||||
tp2Price,
|
||||
stopLossPrice,
|
||||
tp1SizePercent: config.takeProfit1SizePercent || 50,
|
||||
tp2SizePercent: config.takeProfit2SizePercent || 100,
|
||||
direction: body.direction,
|
||||
// Dual stop parameters
|
||||
useDualStops: config.useDualStops,
|
||||
softStopPrice: softStopPrice,
|
||||
softStopBuffer: config.softStopBuffer,
|
||||
hardStopPrice: hardStopPrice,
|
||||
})
|
||||
|
||||
if (!exitRes.success) {
|
||||
console.error('❌ Failed to place on-chain exit orders:', exitRes.error)
|
||||
} else {
|
||||
console.log('📨 Exit orders placed on-chain:', exitRes.signatures)
|
||||
exitOrderSignatures = exitRes.signatures || []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Unexpected error placing exit orders:', err)
|
||||
}
|
||||
|
||||
// Add to position manager for monitoring AFTER orders are placed
|
||||
await positionManager.addTrade(activeTrade)
|
||||
|
||||
console.log('✅ Trade added to position manager for monitoring')
|
||||
@@ -236,38 +326,9 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Place on-chain TP/SL orders so they appear in Drift UI (reduce-only LIMIT orders)
|
||||
let exitOrderSignatures: string[] = []
|
||||
try {
|
||||
const exitRes = await placeExitOrders({
|
||||
symbol: driftSymbol,
|
||||
positionSizeUSD: positionSizeUSD,
|
||||
tp1Price,
|
||||
tp2Price,
|
||||
stopLossPrice,
|
||||
tp1SizePercent: config.takeProfit1SizePercent || 50,
|
||||
tp2SizePercent: config.takeProfit2SizePercent || 100,
|
||||
direction: body.direction,
|
||||
// Dual stop parameters
|
||||
useDualStops: config.useDualStops,
|
||||
softStopPrice: softStopPrice,
|
||||
softStopBuffer: config.softStopBuffer,
|
||||
hardStopPrice: hardStopPrice,
|
||||
})
|
||||
|
||||
if (!exitRes.success) {
|
||||
console.error('❌ Failed to place on-chain exit orders:', exitRes.error)
|
||||
} else {
|
||||
console.log('📨 Exit orders placed on-chain:', exitRes.signatures)
|
||||
exitOrderSignatures = exitRes.signatures || []
|
||||
}
|
||||
|
||||
// Attach signatures to response when available
|
||||
if (exitRes.signatures && exitRes.signatures.length > 0) {
|
||||
;(response as any).exitOrderSignatures = exitRes.signatures
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Unexpected error placing exit orders:', err)
|
||||
// Attach exit order signatures to response
|
||||
if (exitOrderSignatures.length > 0) {
|
||||
(response as any).exitOrderSignatures = exitOrderSignatures
|
||||
}
|
||||
|
||||
// Save trade to database
|
||||
@@ -277,6 +338,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
symbol: driftSymbol,
|
||||
direction: body.direction,
|
||||
entryPrice,
|
||||
entrySlippage: openResult.slippage,
|
||||
positionSizeUSD: positionSizeUSD,
|
||||
leverage: config.leverage,
|
||||
stopLossPrice,
|
||||
@@ -295,12 +357,15 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
hardStopPrice,
|
||||
signalStrength: body.signalStrength,
|
||||
timeframe: body.timeframe,
|
||||
// Market context
|
||||
expectedEntryPrice,
|
||||
fundingRateAtEntry,
|
||||
})
|
||||
|
||||
console.log('💾 Trade saved to database')
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save trade to database:', dbError)
|
||||
// Don't fail the trade if database save fails
|
||||
// Don't fail the database save fails
|
||||
}
|
||||
|
||||
console.log('✅ Trade executed successfully!')
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getPositionManager } from '@/lib/trading/position-manager'
|
||||
import { getInitializedPositionManager } from '@/lib/trading/position-manager'
|
||||
|
||||
export interface PositionsResponse {
|
||||
success: boolean
|
||||
@@ -57,7 +57,7 @@ export async function GET(request: NextRequest): Promise<NextResponse<PositionsR
|
||||
)
|
||||
}
|
||||
|
||||
const positionManager = getPositionManager()
|
||||
const positionManager = await getInitializedPositionManager()
|
||||
const status = positionManager.getStatus()
|
||||
const trades = positionManager.getActiveTrades()
|
||||
|
||||
|
||||
253
app/api/trading/reduce-position/route.ts
Normal file
253
app/api/trading/reduce-position/route.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Reduce Position API Endpoint
|
||||
*
|
||||
* Partially closes a position and recalculates TP/SL orders
|
||||
* POST /api/trading/reduce-position
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMergedConfig } from '@/config/trading'
|
||||
import { getInitializedPositionManager } from '@/lib/trading/position-manager'
|
||||
import { initializeDriftService } from '@/lib/drift/client'
|
||||
import { closePosition, placeExitOrders, cancelAllOrders } from '@/lib/drift/orders'
|
||||
|
||||
interface ReducePositionRequest {
|
||||
tradeId: string
|
||||
reducePercent?: number // 25 = close 25%, 50 = close 50%
|
||||
}
|
||||
|
||||
interface ReducePositionResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
closedSize?: number
|
||||
remainingSize?: number
|
||||
closePrice?: number
|
||||
realizedPnL?: number
|
||||
newTP1?: number
|
||||
newTP2?: number
|
||||
newSL?: number
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse<ReducePositionResponse>> {
|
||||
try {
|
||||
// Verify authorization
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const expectedAuth = `Bearer ${process.env.API_SECRET_KEY}`
|
||||
|
||||
if (!authHeader || authHeader !== expectedAuth) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body: ReducePositionRequest = await request.json()
|
||||
|
||||
console.log('📉 Reducing position:', body)
|
||||
|
||||
if (!body.tradeId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'tradeId is required',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const reducePercent = body.reducePercent || 50 // Default: close 50%
|
||||
|
||||
if (reducePercent < 10 || reducePercent > 100) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Reduce percent must be between 10 and 100',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// If reducing 100%, use the close endpoint logic instead
|
||||
if (reducePercent === 100) {
|
||||
// Get Position Manager
|
||||
const positionManager = await getInitializedPositionManager()
|
||||
const activeTrades = positionManager.getActiveTrades()
|
||||
const trade = activeTrades.find(t => t.id === body.tradeId)
|
||||
|
||||
if (!trade) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Position ${body.tradeId} not found`,
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`🔴 Closing 100% of position: ${trade.symbol}`)
|
||||
|
||||
// Initialize Drift service
|
||||
await initializeDriftService()
|
||||
|
||||
// Close entire position (this will automatically cancel all orders)
|
||||
const closeResult = await closePosition({
|
||||
symbol: trade.symbol,
|
||||
percentToClose: 100,
|
||||
slippageTolerance: getMergedConfig().slippageTolerance,
|
||||
})
|
||||
|
||||
if (!closeResult.success) {
|
||||
throw new Error(`Failed to close position: ${closeResult.error}`)
|
||||
}
|
||||
|
||||
console.log(`✅ Position fully closed | P&L: $${closeResult.realizedPnL || 0}`)
|
||||
console.log(`✅ All TP/SL orders cancelled automatically`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Position closed 100%`,
|
||||
closedSize: trade.positionSize,
|
||||
remainingSize: 0,
|
||||
closePrice: closeResult.closePrice,
|
||||
realizedPnL: closeResult.realizedPnL,
|
||||
newTP1: 0,
|
||||
newTP2: 0,
|
||||
newSL: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Get current configuration
|
||||
const config = getMergedConfig()
|
||||
|
||||
// Get Position Manager
|
||||
const positionManager = await getInitializedPositionManager()
|
||||
const activeTrades = positionManager.getActiveTrades()
|
||||
const trade = activeTrades.find(t => t.id === body.tradeId)
|
||||
|
||||
if (!trade) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Position ${body.tradeId} not found`,
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`📊 Current position: ${trade.symbol} ${trade.direction}`)
|
||||
console.log(` Entry: $${trade.entryPrice}`)
|
||||
console.log(` Size: ${trade.currentSize} (${trade.positionSize} USD)`)
|
||||
console.log(` Reducing by: ${reducePercent}%`)
|
||||
|
||||
// Initialize Drift service
|
||||
const driftService = await initializeDriftService()
|
||||
|
||||
// Close portion of position at market
|
||||
console.log(`💰 Closing ${reducePercent}% of position...`)
|
||||
|
||||
const closeResult = await closePosition({
|
||||
symbol: trade.symbol,
|
||||
percentToClose: reducePercent,
|
||||
slippageTolerance: config.slippageTolerance,
|
||||
})
|
||||
|
||||
if (!closeResult.success || !closeResult.closePrice) {
|
||||
throw new Error(`Failed to close position: ${closeResult.error}`)
|
||||
}
|
||||
|
||||
console.log(`✅ Closed at $${closeResult.closePrice}`)
|
||||
console.log(`💵 Realized P&L: $${closeResult.realizedPnL || 0}`)
|
||||
|
||||
// Calculate remaining position size
|
||||
const remainingPercent = 100 - reducePercent
|
||||
const remainingSizeUSD = (trade.positionSize * remainingPercent) / 100
|
||||
|
||||
console.log(`📊 Remaining position: $${remainingSizeUSD} (${remainingPercent}%)`)
|
||||
|
||||
// Cancel all existing exit orders
|
||||
console.log('🗑️ Cancelling old TP/SL orders...')
|
||||
try {
|
||||
await cancelAllOrders(trade.symbol)
|
||||
console.log('✅ Old orders cancelled')
|
||||
} catch (cancelError) {
|
||||
console.error('⚠️ Failed to cancel orders:', cancelError)
|
||||
// Continue anyway
|
||||
}
|
||||
|
||||
// Calculate TP/SL prices (entry price stays the same)
|
||||
const calculatePrice = (entry: number, percent: number, direction: 'long' | 'short') => {
|
||||
if (direction === 'long') {
|
||||
return entry * (1 + percent / 100)
|
||||
} else {
|
||||
return entry * (1 - percent / 100)
|
||||
}
|
||||
}
|
||||
|
||||
const newTP1 = calculatePrice(trade.entryPrice, config.takeProfit1Percent, trade.direction)
|
||||
const newTP2 = calculatePrice(trade.entryPrice, config.takeProfit2Percent, trade.direction)
|
||||
const newSL = calculatePrice(trade.entryPrice, config.stopLossPercent, trade.direction)
|
||||
|
||||
console.log(`🎯 New targets (same entry, reduced size):`)
|
||||
console.log(` TP1: $${newTP1} (${config.takeProfit1Percent}%)`)
|
||||
console.log(` TP2: $${newTP2} (${config.takeProfit2Percent}%)`)
|
||||
console.log(` SL: $${newSL} (${config.stopLossPercent}%)`)
|
||||
|
||||
// Place new exit orders with reduced size
|
||||
console.log('📝 Placing new TP/SL orders...')
|
||||
const exitOrders = await placeExitOrders({
|
||||
symbol: trade.symbol,
|
||||
direction: trade.direction,
|
||||
positionSizeUSD: remainingSizeUSD,
|
||||
entryPrice: trade.entryPrice,
|
||||
tp1Price: newTP1,
|
||||
tp2Price: newTP2,
|
||||
stopLossPrice: newSL,
|
||||
tp1SizePercent: config.takeProfit1SizePercent,
|
||||
tp2SizePercent: config.takeProfit2SizePercent,
|
||||
useDualStops: config.useDualStops,
|
||||
softStopPrice: config.useDualStops ? calculatePrice(trade.entryPrice, config.softStopPercent, trade.direction) : undefined,
|
||||
softStopBuffer: config.useDualStops ? config.softStopBuffer : undefined,
|
||||
hardStopPrice: config.useDualStops ? calculatePrice(trade.entryPrice, config.hardStopPercent, trade.direction) : undefined,
|
||||
})
|
||||
|
||||
console.log(`✅ New exit orders placed`)
|
||||
|
||||
// Update Position Manager with new values
|
||||
trade.positionSize = remainingSizeUSD
|
||||
trade.currentSize = remainingSizeUSD
|
||||
trade.realizedPnL += closeResult.realizedPnL || 0
|
||||
|
||||
// Update prices (stay the same but refresh)
|
||||
trade.tp1Price = newTP1
|
||||
trade.tp2Price = newTP2
|
||||
trade.stopLossPrice = newSL
|
||||
|
||||
console.log(`💾 Updated Position Manager`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Reduced position by ${reducePercent}% - Remaining: $${remainingSizeUSD.toFixed(0)}`,
|
||||
closedSize: (trade.positionSize * reducePercent) / 100,
|
||||
remainingSize: remainingSizeUSD,
|
||||
closePrice: closeResult.closePrice,
|
||||
realizedPnL: closeResult.realizedPnL,
|
||||
newTP1: newTP1,
|
||||
newTP2: newTP2,
|
||||
newSL: newSL,
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Reduce position error:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
115
app/api/trading/remove-position/route.ts
Normal file
115
app/api/trading/remove-position/route.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Remove Position from Tracking
|
||||
*
|
||||
* Manually removes a position from Position Manager tracking
|
||||
* POST /api/trading/remove-position
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getInitializedPositionManager } from '@/lib/trading/position-manager'
|
||||
import { updateTradeExit } from '@/lib/database/trades'
|
||||
|
||||
interface RemovePositionRequest {
|
||||
tradeId: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface RemovePositionResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
tradeId?: string
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse<RemovePositionResponse>> {
|
||||
try {
|
||||
// Verify authorization
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const expectedAuth = `Bearer ${process.env.API_SECRET_KEY}`
|
||||
|
||||
if (!authHeader || authHeader !== expectedAuth) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body: RemovePositionRequest = await request.json()
|
||||
|
||||
console.log('🗑️ Removing position from tracking:', body)
|
||||
|
||||
if (!body.tradeId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'tradeId is required',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get Position Manager
|
||||
const positionManager = await getInitializedPositionManager()
|
||||
|
||||
// Check if position exists
|
||||
const activeTrades = positionManager.getActiveTrades()
|
||||
const trade = activeTrades.find(t => t.id === body.tradeId)
|
||||
|
||||
if (!trade) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Position ${body.tradeId} not found in tracking`,
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`📊 Found position: ${trade.symbol} ${trade.direction} at $${trade.entryPrice}`)
|
||||
|
||||
// Remove from Position Manager
|
||||
positionManager.removeTrade(body.tradeId)
|
||||
|
||||
console.log(`✅ Removed ${body.tradeId} from Position Manager`)
|
||||
|
||||
// Update database to mark as closed (manually)
|
||||
try {
|
||||
const exitTime = new Date()
|
||||
const holdTime = Math.floor((exitTime.getTime() - new Date(trade.entryTime).getTime()) / 1000)
|
||||
|
||||
await updateTradeExit({
|
||||
positionId: trade.positionId || 'manual-removal',
|
||||
exitPrice: trade.lastPrice || trade.entryPrice,
|
||||
exitReason: 'manual',
|
||||
realizedPnL: trade.unrealizedPnL,
|
||||
exitOrderTx: 'manual-removal',
|
||||
holdTimeSeconds: holdTime,
|
||||
maxDrawdown: trade.peakPnL < 0 ? trade.peakPnL : undefined,
|
||||
maxGain: trade.peakPnL > 0 ? trade.peakPnL : undefined,
|
||||
})
|
||||
console.log('💾 Updated database: trade marked as closed')
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to update database:', dbError)
|
||||
// Don't fail the removal if database update fails
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Position removed from tracking: ${trade.symbol} ${trade.direction}`,
|
||||
tradeId: body.tradeId,
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Remove position error:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
225
app/api/trading/scale-position/route.ts
Normal file
225
app/api/trading/scale-position/route.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Scale Position API Endpoint
|
||||
*
|
||||
* Adds to an existing position and recalculates TP/SL orders
|
||||
* POST /api/trading/scale-position
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMergedConfig } from '@/config/trading'
|
||||
import { getInitializedPositionManager } from '@/lib/trading/position-manager'
|
||||
import { initializeDriftService } from '@/lib/drift/client'
|
||||
import { openPosition, placeExitOrders, cancelAllOrders } from '@/lib/drift/orders'
|
||||
|
||||
interface ScalePositionRequest {
|
||||
tradeId: string
|
||||
scalePercent?: number // 50 = add 50%, 100 = double position
|
||||
}
|
||||
|
||||
interface ScalePositionResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
oldEntry?: number
|
||||
newEntry?: number
|
||||
oldSize?: number
|
||||
newSize?: number
|
||||
newTP1?: number
|
||||
newTP2?: number
|
||||
newSL?: number
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse<ScalePositionResponse>> {
|
||||
try {
|
||||
// Verify authorization
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const expectedAuth = `Bearer ${process.env.API_SECRET_KEY}`
|
||||
|
||||
if (!authHeader || authHeader !== expectedAuth) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body: ScalePositionRequest = await request.json()
|
||||
|
||||
console.log('📈 Scaling position:', body)
|
||||
|
||||
if (!body.tradeId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'tradeId is required',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const scalePercent = body.scalePercent || 50 // Default: add 50%
|
||||
|
||||
// Get current configuration
|
||||
const config = getMergedConfig()
|
||||
|
||||
// Get Position Manager
|
||||
const positionManager = await getInitializedPositionManager()
|
||||
const activeTrades = positionManager.getActiveTrades()
|
||||
const trade = activeTrades.find(t => t.id === body.tradeId)
|
||||
|
||||
if (!trade) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Position ${body.tradeId} not found`,
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`📊 Current position: ${trade.symbol} ${trade.direction}`)
|
||||
console.log(` Entry: $${trade.entryPrice}`)
|
||||
console.log(` Size: ${trade.currentSize} (${trade.positionSize} USD)`)
|
||||
console.log(` Scaling by: ${scalePercent}%`)
|
||||
|
||||
// Initialize Drift service
|
||||
const driftService = await initializeDriftService()
|
||||
|
||||
// Check account health before scaling
|
||||
const healthData = await driftService.getAccountHealth()
|
||||
const healthPercent = healthData.marginRatio
|
||||
console.log(`💊 Account health: ${healthPercent}%`)
|
||||
|
||||
if (healthPercent < 30) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Account health too low (${healthPercent}%) to scale position`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate additional position size
|
||||
const additionalSizeUSD = (trade.positionSize * scalePercent) / 100
|
||||
|
||||
console.log(`💰 Adding $${additionalSizeUSD} to position...`)
|
||||
|
||||
// Open additional position at market
|
||||
const addResult = await openPosition({
|
||||
symbol: trade.symbol,
|
||||
direction: trade.direction,
|
||||
sizeUSD: additionalSizeUSD,
|
||||
slippageTolerance: config.slippageTolerance,
|
||||
})
|
||||
|
||||
if (!addResult.success || !addResult.fillPrice) {
|
||||
throw new Error(`Failed to open additional position: ${addResult.error}`)
|
||||
}
|
||||
|
||||
console.log(`✅ Additional position opened at $${addResult.fillPrice}`)
|
||||
|
||||
// Calculate new average entry price
|
||||
const oldTotalValue = trade.positionSize
|
||||
const newTotalValue = oldTotalValue + additionalSizeUSD
|
||||
const oldEntry = trade.entryPrice
|
||||
const newEntryContribution = addResult.fillPrice
|
||||
|
||||
// Weighted average: (old_size * old_price + new_size * new_price) / total_size
|
||||
const newAvgEntry = (
|
||||
(oldTotalValue * oldEntry) + (additionalSizeUSD * newEntryContribution)
|
||||
) / newTotalValue
|
||||
|
||||
console.log(`📊 New average entry: $${oldEntry} → $${newAvgEntry}`)
|
||||
console.log(`📊 New position size: $${oldTotalValue} → $${newTotalValue}`)
|
||||
|
||||
// Cancel all existing exit orders
|
||||
console.log('🗑️ Cancelling old TP/SL orders...')
|
||||
try {
|
||||
await cancelAllOrders(trade.symbol)
|
||||
console.log('✅ Old orders cancelled')
|
||||
} catch (cancelError) {
|
||||
console.error('⚠️ Failed to cancel orders:', cancelError)
|
||||
// Continue anyway - might not have any orders
|
||||
}
|
||||
|
||||
// Calculate new TP/SL prices based on new average entry
|
||||
const calculatePrice = (entry: number, percent: number, direction: 'long' | 'short') => {
|
||||
if (direction === 'long') {
|
||||
return entry * (1 + percent / 100)
|
||||
} else {
|
||||
return entry * (1 - percent / 100)
|
||||
}
|
||||
}
|
||||
|
||||
const newTP1 = calculatePrice(newAvgEntry, config.takeProfit1Percent, trade.direction)
|
||||
const newTP2 = calculatePrice(newAvgEntry, config.takeProfit2Percent, trade.direction)
|
||||
const newSL = calculatePrice(newAvgEntry, config.stopLossPercent, trade.direction)
|
||||
|
||||
console.log(`🎯 New targets:`)
|
||||
console.log(` TP1: $${newTP1} (${config.takeProfit1Percent}%)`)
|
||||
console.log(` TP2: $${newTP2} (${config.takeProfit2Percent}%)`)
|
||||
console.log(` SL: $${newSL} (${config.stopLossPercent}%)`)
|
||||
|
||||
// Place new exit orders
|
||||
console.log('📝 Placing new TP/SL orders...')
|
||||
const exitOrders = await placeExitOrders({
|
||||
symbol: trade.symbol,
|
||||
direction: trade.direction,
|
||||
positionSizeUSD: newTotalValue,
|
||||
entryPrice: newAvgEntry,
|
||||
tp1Price: newTP1,
|
||||
tp2Price: newTP2,
|
||||
stopLossPrice: newSL,
|
||||
tp1SizePercent: config.takeProfit1SizePercent,
|
||||
tp2SizePercent: config.takeProfit2SizePercent,
|
||||
useDualStops: config.useDualStops,
|
||||
softStopPrice: config.useDualStops ? calculatePrice(newAvgEntry, config.softStopPercent, trade.direction) : undefined,
|
||||
softStopBuffer: config.useDualStops ? config.softStopBuffer : undefined,
|
||||
hardStopPrice: config.useDualStops ? calculatePrice(newAvgEntry, config.hardStopPercent, trade.direction) : undefined,
|
||||
})
|
||||
|
||||
console.log(`✅ New exit orders placed`)
|
||||
|
||||
// Update Position Manager with new values
|
||||
trade.entryPrice = newAvgEntry
|
||||
trade.positionSize = newTotalValue
|
||||
trade.currentSize = newTotalValue
|
||||
trade.tp1Price = newTP1
|
||||
trade.tp2Price = newTP2
|
||||
trade.stopLossPrice = newSL
|
||||
|
||||
// Reset tracking values
|
||||
trade.tp1Hit = false
|
||||
trade.slMovedToBreakeven = false
|
||||
trade.slMovedToProfit = false
|
||||
trade.peakPnL = 0
|
||||
trade.peakPrice = newAvgEntry
|
||||
|
||||
console.log(`💾 Updated Position Manager`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Position scaled by ${scalePercent}% - New entry: $${newAvgEntry.toFixed(2)}`,
|
||||
oldEntry: oldEntry,
|
||||
newEntry: newAvgEntry,
|
||||
oldSize: oldTotalValue,
|
||||
newSize: newTotalValue,
|
||||
newTP1: newTP1,
|
||||
newTP2: newTP2,
|
||||
newSL: newSL,
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Scale position error:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { initializeDriftService } from '@/lib/drift/client'
|
||||
import { openPosition, placeExitOrders } from '@/lib/drift/orders'
|
||||
import { getMergedConfig } from '@/config/trading'
|
||||
import { getPositionManager, ActiveTrade } from '@/lib/trading/position-manager'
|
||||
import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager'
|
||||
import { createTrade } from '@/lib/database/trades'
|
||||
|
||||
export interface TestTradeRequest {
|
||||
@@ -134,6 +134,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<TestTrade
|
||||
const exitRes = await placeExitOrders({
|
||||
symbol,
|
||||
positionSizeUSD,
|
||||
entryPrice,
|
||||
tp1Price,
|
||||
tp2Price,
|
||||
stopLossPrice,
|
||||
@@ -181,10 +182,15 @@ export async function POST(request: NextRequest): Promise<NextResponse<TestTrade
|
||||
priceCheckCount: 0,
|
||||
lastPrice: entryPrice,
|
||||
lastUpdateTime: Date.now(),
|
||||
maxFavorableExcursion: 0,
|
||||
maxAdverseExcursion: 0,
|
||||
maxFavorablePrice: entryPrice,
|
||||
maxAdversePrice: entryPrice,
|
||||
lastDbMetricsUpdate: Date.now(),
|
||||
}
|
||||
|
||||
// Add to position manager
|
||||
const positionManager = getPositionManager()
|
||||
const positionManager = await getInitializedPositionManager()
|
||||
await positionManager.addTrade(activeTrade)
|
||||
console.log('✅ Test trade added to position manager')
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { initializeDriftService } from '@/lib/drift/client'
|
||||
import { openPosition, placeExitOrders } from '@/lib/drift/orders'
|
||||
import { normalizeTradingViewSymbol } from '@/config/trading'
|
||||
import { getMergedConfig } from '@/config/trading'
|
||||
import { getPositionManager, ActiveTrade } from '@/lib/trading/position-manager'
|
||||
import { getInitializedPositionManager, ActiveTrade } from '@/lib/trading/position-manager'
|
||||
import { createTrade } from '@/lib/database/trades'
|
||||
|
||||
export interface TestTradeRequest {
|
||||
@@ -180,10 +180,15 @@ export async function POST(request: NextRequest): Promise<NextResponse<TestTrade
|
||||
priceCheckCount: 0,
|
||||
lastPrice: entryPrice,
|
||||
lastUpdateTime: Date.now(),
|
||||
maxFavorableExcursion: 0,
|
||||
maxAdverseExcursion: 0,
|
||||
maxFavorablePrice: entryPrice,
|
||||
maxAdversePrice: entryPrice,
|
||||
lastDbMetricsUpdate: Date.now(),
|
||||
}
|
||||
|
||||
// Add to position manager for monitoring
|
||||
const positionManager = getPositionManager()
|
||||
const positionManager = await getInitializedPositionManager()
|
||||
await positionManager.addTrade(activeTrade)
|
||||
|
||||
console.log('✅ Trade added to position manager for monitoring')
|
||||
@@ -211,6 +216,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<TestTrade
|
||||
const exitRes = await placeExitOrders({
|
||||
symbol: driftSymbol,
|
||||
positionSizeUSD: positionSizeUSD,
|
||||
entryPrice: entryPrice,
|
||||
tp1Price,
|
||||
tp2Price,
|
||||
stopLossPrice,
|
||||
|
||||
240
app/api/trading/validate-positions/route.ts
Normal file
240
app/api/trading/validate-positions/route.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Validate Positions API Endpoint
|
||||
*
|
||||
* Compares current open positions against configured settings
|
||||
* POST /api/trading/validate-positions
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getMergedConfig } from '@/config/trading'
|
||||
import { getInitializedPositionManager } from '@/lib/trading/position-manager'
|
||||
import { getDriftService } from '@/lib/drift/client'
|
||||
|
||||
interface ValidationIssue {
|
||||
type: 'error' | 'warning'
|
||||
field: string
|
||||
expected: number | string
|
||||
actual: number | string
|
||||
message: string
|
||||
}
|
||||
|
||||
interface PositionValidation {
|
||||
symbol: string
|
||||
direction: 'long' | 'short'
|
||||
entryPrice: number
|
||||
isValid: boolean
|
||||
issues: ValidationIssue[]
|
||||
}
|
||||
|
||||
interface ValidationResponse {
|
||||
success: boolean
|
||||
timestamp: string
|
||||
config: {
|
||||
leverage: number
|
||||
positionSize: number
|
||||
tp1Percent: number
|
||||
tp2Percent: number
|
||||
stopLossPercent: number
|
||||
useDualStops: boolean
|
||||
hardStopPercent?: number
|
||||
}
|
||||
positions: PositionValidation[]
|
||||
summary: {
|
||||
totalPositions: number
|
||||
validPositions: number
|
||||
positionsWithIssues: number
|
||||
}
|
||||
}
|
||||
|
||||
function calculateExpectedPrice(entry: number, percent: number, direction: 'long' | 'short'): number {
|
||||
if (direction === 'long') {
|
||||
return entry * (1 + percent / 100)
|
||||
} else {
|
||||
return entry * (1 - percent / 100)
|
||||
}
|
||||
}
|
||||
|
||||
function calculateActualPercent(entry: number, price: number, direction: 'long' | 'short'): number {
|
||||
if (direction === 'long') {
|
||||
return ((price - entry) / entry) * 100
|
||||
} else {
|
||||
return ((entry - price) / entry) * 100
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest): Promise<NextResponse<ValidationResponse>> {
|
||||
try {
|
||||
// Verify authorization
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const expectedAuth = `Bearer ${process.env.API_SECRET_KEY}`
|
||||
|
||||
if (!authHeader || authHeader !== expectedAuth) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
config: {} as any,
|
||||
positions: [],
|
||||
summary: {
|
||||
totalPositions: 0,
|
||||
validPositions: 0,
|
||||
positionsWithIssues: 0,
|
||||
},
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
console.log('🔍 Validating positions against settings...')
|
||||
|
||||
// Get current configuration
|
||||
const config = getMergedConfig()
|
||||
|
||||
// Get active positions from Position Manager
|
||||
const positionManager = await getInitializedPositionManager()
|
||||
const activeTrades = Array.from(positionManager.getActiveTrades().values())
|
||||
|
||||
console.log(`📊 Found ${activeTrades.length} active positions to validate`)
|
||||
|
||||
const validations: PositionValidation[] = []
|
||||
|
||||
for (const trade of activeTrades) {
|
||||
const issues: ValidationIssue[] = []
|
||||
|
||||
// Validate leverage
|
||||
const expectedLeverage = config.leverage
|
||||
if (trade.leverage !== expectedLeverage) {
|
||||
issues.push({
|
||||
type: 'warning',
|
||||
field: 'leverage',
|
||||
expected: expectedLeverage,
|
||||
actual: trade.leverage,
|
||||
message: `Leverage mismatch: expected ${expectedLeverage}x, got ${trade.leverage}x`,
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate expected prices based on current config
|
||||
const expectedTP1 = calculateExpectedPrice(trade.entryPrice, config.takeProfit1Percent, trade.direction)
|
||||
const expectedTP2 = calculateExpectedPrice(trade.entryPrice, config.takeProfit2Percent, trade.direction)
|
||||
const expectedSL = calculateExpectedPrice(trade.entryPrice, config.stopLossPercent, trade.direction)
|
||||
|
||||
// Validate TP1 (allow 0.1% tolerance)
|
||||
const tp1Diff = Math.abs((trade.tp1Price - expectedTP1) / expectedTP1) * 100
|
||||
if (tp1Diff > 0.1) {
|
||||
const actualTP1Percent = calculateActualPercent(trade.entryPrice, trade.tp1Price, trade.direction)
|
||||
issues.push({
|
||||
type: 'error',
|
||||
field: 'takeProfit1',
|
||||
expected: `${config.takeProfit1Percent}% ($${expectedTP1.toFixed(2)})`,
|
||||
actual: `${actualTP1Percent.toFixed(2)}% ($${trade.tp1Price.toFixed(2)})`,
|
||||
message: `TP1 price mismatch: expected ${config.takeProfit1Percent}%, actual ${actualTP1Percent.toFixed(2)}%`,
|
||||
})
|
||||
}
|
||||
|
||||
// Validate TP2 (allow 0.1% tolerance)
|
||||
const tp2Diff = Math.abs((trade.tp2Price - expectedTP2) / expectedTP2) * 100
|
||||
if (tp2Diff > 0.1) {
|
||||
const actualTP2Percent = calculateActualPercent(trade.entryPrice, trade.tp2Price, trade.direction)
|
||||
issues.push({
|
||||
type: 'error',
|
||||
field: 'takeProfit2',
|
||||
expected: `${config.takeProfit2Percent}% ($${expectedTP2.toFixed(2)})`,
|
||||
actual: `${actualTP2Percent.toFixed(2)}% ($${trade.tp2Price.toFixed(2)})`,
|
||||
message: `TP2 price mismatch: expected ${config.takeProfit2Percent}%, actual ${actualTP2Percent.toFixed(2)}%`,
|
||||
})
|
||||
}
|
||||
|
||||
// Validate Stop Loss (allow 0.1% tolerance)
|
||||
const slDiff = Math.abs((trade.stopLossPrice - expectedSL) / expectedSL) * 100
|
||||
if (slDiff > 0.1) {
|
||||
const actualSLPercent = Math.abs(calculateActualPercent(trade.entryPrice, trade.stopLossPrice, trade.direction))
|
||||
issues.push({
|
||||
type: 'error',
|
||||
field: 'stopLoss',
|
||||
expected: `${Math.abs(config.stopLossPercent)}% ($${expectedSL.toFixed(2)})`,
|
||||
actual: `${actualSLPercent.toFixed(2)}% ($${trade.stopLossPrice.toFixed(2)})`,
|
||||
message: `Stop loss mismatch: expected ${Math.abs(config.stopLossPercent)}%, actual ${actualSLPercent.toFixed(2)}%`,
|
||||
})
|
||||
}
|
||||
|
||||
// Validate position size
|
||||
// Note: trade.positionSize is the TOTAL position value in USD (e.g., $800 with 10x leverage)
|
||||
// config.positionSize is the COLLATERAL amount (e.g., $80)
|
||||
// So: expectedPositionValueUSD = config.positionSize * config.leverage
|
||||
const expectedPositionValueUSD = config.positionSize * config.leverage
|
||||
const actualPositionValueUSD = trade.positionSize
|
||||
const sizeDiff = Math.abs((actualPositionValueUSD - expectedPositionValueUSD) / expectedPositionValueUSD) * 100
|
||||
|
||||
if (sizeDiff > 5) { // Allow 5% tolerance for position size
|
||||
issues.push({
|
||||
type: 'warning',
|
||||
field: 'positionSize',
|
||||
expected: `$${expectedPositionValueUSD.toFixed(2)}`,
|
||||
actual: `$${actualPositionValueUSD.toFixed(2)}`,
|
||||
message: `Position size mismatch: expected $${expectedPositionValueUSD.toFixed(2)}, got $${actualPositionValueUSD.toFixed(2)}`,
|
||||
})
|
||||
}
|
||||
|
||||
const validation: PositionValidation = {
|
||||
symbol: trade.symbol,
|
||||
direction: trade.direction,
|
||||
entryPrice: trade.entryPrice,
|
||||
isValid: issues.length === 0,
|
||||
issues,
|
||||
}
|
||||
|
||||
validations.push(validation)
|
||||
|
||||
if (issues.length > 0) {
|
||||
console.log(`⚠️ Position ${trade.symbol} ${trade.direction} has ${issues.length} issue(s):`)
|
||||
issues.forEach(issue => {
|
||||
console.log(` ${issue.type === 'error' ? '❌' : '⚠️'} ${issue.message}`)
|
||||
})
|
||||
} else {
|
||||
console.log(`✅ Position ${trade.symbol} ${trade.direction} is valid`)
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
totalPositions: validations.length,
|
||||
validPositions: validations.filter(v => v.isValid).length,
|
||||
positionsWithIssues: validations.filter(v => !v.isValid).length,
|
||||
}
|
||||
|
||||
console.log(`📊 Validation complete: ${summary.validPositions}/${summary.totalPositions} positions valid`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
config: {
|
||||
leverage: config.leverage,
|
||||
positionSize: config.positionSize,
|
||||
tp1Percent: config.takeProfit1Percent,
|
||||
tp2Percent: config.takeProfit2Percent,
|
||||
stopLossPercent: config.stopLossPercent,
|
||||
useDualStops: config.useDualStops,
|
||||
hardStopPercent: config.useDualStops ? config.hardStopPercent : undefined,
|
||||
},
|
||||
positions: validations,
|
||||
summary,
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Position validation error:', error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
timestamp: new Date().toISOString(),
|
||||
config: {} as any,
|
||||
positions: [],
|
||||
summary: {
|
||||
totalPositions: 0,
|
||||
validPositions: 0,
|
||||
positionsWithIssues: 0,
|
||||
},
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -385,12 +385,12 @@ export default function SettingsPage() {
|
||||
description="Maximum number of trades allowed per hour."
|
||||
/>
|
||||
<Setting
|
||||
label="Cooldown Between Trades (seconds)"
|
||||
label="Cooldown Between Trades (minutes)"
|
||||
value={settings.MIN_TIME_BETWEEN_TRADES}
|
||||
onChange={(v) => updateSetting('MIN_TIME_BETWEEN_TRADES', v)}
|
||||
min={0}
|
||||
max={3600}
|
||||
step={60}
|
||||
max={60}
|
||||
step={1}
|
||||
description="Minimum wait time between trades to prevent overtrading."
|
||||
/>
|
||||
</Section>
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface TradingConfig {
|
||||
// Risk limits
|
||||
maxDailyDrawdown: number // USD stop trading threshold
|
||||
maxTradesPerHour: number // Limit overtrading
|
||||
minTimeBetweenTrades: number // Cooldown period (seconds)
|
||||
minTimeBetweenTrades: number // Cooldown period (minutes)
|
||||
|
||||
// Execution
|
||||
useMarketOrders: boolean // true = instant execution
|
||||
@@ -91,13 +91,13 @@ export const DEFAULT_TRADING_CONFIG: TradingConfig = {
|
||||
// Risk limits
|
||||
maxDailyDrawdown: -150, // Stop trading if daily loss exceeds $150 (-15%)
|
||||
maxTradesPerHour: 6, // Max 6 trades per hour
|
||||
minTimeBetweenTrades: 600, // 10 minutes cooldown
|
||||
minTimeBetweenTrades: 10, // 10 minutes cooldown
|
||||
|
||||
// Execution
|
||||
useMarketOrders: true, // Use market orders for reliable fills
|
||||
confirmationTimeout: 30000, // 30 seconds max wait
|
||||
takeProfit1SizePercent: 75, // Close 75% at TP1 to lock in profit
|
||||
takeProfit2SizePercent: 100, // Close remaining 25% at TP2
|
||||
takeProfit2SizePercent: 80, // Close 80% of remaining 25% at TP2 (leaves 5% as runner)
|
||||
}
|
||||
|
||||
// Supported markets on Drift Protocol
|
||||
@@ -234,6 +234,9 @@ export function getConfigFromEnv(): Partial<TradingConfig> {
|
||||
maxTradesPerHour: process.env.MAX_TRADES_PER_HOUR
|
||||
? parseInt(process.env.MAX_TRADES_PER_HOUR)
|
||||
: undefined,
|
||||
minTimeBetweenTrades: process.env.MIN_TIME_BETWEEN_TRADES
|
||||
? parseInt(process.env.MIN_TIME_BETWEEN_TRADES)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ services:
|
||||
dockerfile: Dockerfile.telegram-bot
|
||||
container_name: telegram-trade-bot
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.telegram-bot
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 8.8.4.4
|
||||
|
||||
146
docs/history/DUPLICATE_POSITION_FIX.md
Normal file
146
docs/history/DUPLICATE_POSITION_FIX.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Duplicate Position Prevention - Fix Documentation
|
||||
|
||||
**Date:** 2025-01-27
|
||||
**Issue:** Multiple positions opened on same symbol from different timeframe signals
|
||||
**Status:** ✅ RESOLVED
|
||||
|
||||
## Problem Description
|
||||
|
||||
User received TradingView alerts on both 15-minute AND 30-minute charts for SOLUSDT. The bot:
|
||||
1. ✅ Correctly extracted timeframe from both alerts (15 and 30)
|
||||
2. ✅ Correctly filtered out the 30-minute signal (as intended)
|
||||
3. ❌ BUT allowed the 15-minute signal even though a position already existed
|
||||
4. ❌ Result: Two LONG positions on SOL-PERP opened 15 minutes apart
|
||||
|
||||
**Root Cause:** Risk check API (`/api/trading/check-risk`) had `TODO` comment for checking existing positions but was always returning `allowed: true`.
|
||||
|
||||
## Solution Implemented
|
||||
|
||||
### 1. Updated Risk Check API
|
||||
**File:** `app/api/trading/check-risk/route.ts`
|
||||
|
||||
**Changes:**
|
||||
- Import `getInitializedPositionManager()` instead of `getPositionManager()`
|
||||
- Wait for Position Manager initialization (restores trades from database)
|
||||
- Check if any active trade exists on the requested symbol
|
||||
- Block trade if duplicate found
|
||||
|
||||
```typescript
|
||||
// Check for existing positions on the same symbol
|
||||
const positionManager = await getInitializedPositionManager()
|
||||
const existingTrades = Array.from(positionManager.getActiveTrades().values())
|
||||
const duplicatePosition = existingTrades.find(trade => trade.symbol === body.symbol)
|
||||
|
||||
if (duplicatePosition) {
|
||||
return NextResponse.json({
|
||||
allowed: false,
|
||||
reason: 'Duplicate position',
|
||||
details: `Already have ${duplicatePosition.direction} position on ${body.symbol} (entry: $${duplicatePosition.entryPrice})`,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Fixed Timing Issue
|
||||
**Problem:** `getPositionManager()` creates instance immediately but trades are restored asynchronously in background.
|
||||
|
||||
**Solution:** Use `getInitializedPositionManager()` which waits for the initialization promise to complete before returning.
|
||||
|
||||
### 3. Updated .dockerignore
|
||||
**Problem:** Test files in `tests/` and `archive/` directories were being included in Docker build, causing TypeScript compilation errors.
|
||||
|
||||
**Solution:** Added to `.dockerignore`:
|
||||
```
|
||||
tests/
|
||||
archive/
|
||||
*.test.ts
|
||||
*.spec.ts
|
||||
```
|
||||
|
||||
## Testing Results
|
||||
|
||||
### Test 1: Duplicate Position (BLOCKED ✅)
|
||||
```bash
|
||||
curl -X POST /api/trading/check-risk \
|
||||
-d '{"symbol":"SOL-PERP","direction":"long"}'
|
||||
|
||||
Response:
|
||||
{
|
||||
"allowed": false,
|
||||
"reason": "Duplicate position",
|
||||
"details": "Already have long position on SOL-PERP (entry: $202.835871)"
|
||||
}
|
||||
```
|
||||
|
||||
**Logs:**
|
||||
```
|
||||
🔍 Risk check for: { symbol: 'SOL-PERP', direction: 'long' }
|
||||
🚫 Risk check BLOCKED: Duplicate position exists {
|
||||
symbol: 'SOL-PERP',
|
||||
existingDirection: 'long',
|
||||
requestedDirection: 'long',
|
||||
existingEntry: 202.835871
|
||||
}
|
||||
```
|
||||
|
||||
### Test 2: Different Symbol (ALLOWED ✅)
|
||||
```bash
|
||||
curl -X POST /api/trading/check-risk \
|
||||
-d '{"symbol":"BTC-PERP","direction":"long"}'
|
||||
|
||||
Response:
|
||||
{
|
||||
"allowed": true,
|
||||
"details": "All risk checks passed"
|
||||
}
|
||||
```
|
||||
|
||||
## System Behavior Now
|
||||
|
||||
**n8n Workflow Flow:**
|
||||
1. TradingView sends alert → n8n webhook
|
||||
2. Extract timeframe from message (`\.P\s+(\d+)` regex)
|
||||
3. **15min Chart Only?** IF node: Check `timeframe == "15"`
|
||||
4. If passed → Call `/api/trading/check-risk`
|
||||
5. **NEW:** Check if position exists on symbol
|
||||
6. If no duplicate → Execute trade via `/api/trading/execute`
|
||||
|
||||
**Risk Check Matrix:**
|
||||
| Scenario | Timeframe Filter | Risk Check | Result |
|
||||
|----------|------------------|------------|---------|
|
||||
| 15min signal, no position | ✅ PASS | ✅ PASS | Trade executes |
|
||||
| 15min signal, position exists | ✅ PASS | 🚫 BLOCK | Trade blocked |
|
||||
| 30min signal, no position | 🚫 BLOCK | N/A | Trade blocked |
|
||||
| 30min signal, position exists | 🚫 BLOCK | N/A | Trade blocked |
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
The risk check API still has TODO items:
|
||||
- [ ] Check daily drawdown limit
|
||||
- [ ] Check trades per hour limit
|
||||
- [ ] Check cooldown period after loss
|
||||
- [ ] Check Drift account health before trade
|
||||
- [ ] Allow opposite direction trades (hedging)?
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `app/api/trading/check-risk/route.ts` - Added duplicate position check
|
||||
2. `.dockerignore` - Excluded test files from Docker build
|
||||
3. Moved `test-*.ts` files from `/` to `archive/`
|
||||
|
||||
## Git Commits
|
||||
|
||||
- **8f90339** - "Add duplicate position prevention to risk check"
|
||||
- **17b0806** - "Add 15-minute chart filter to n8n workflow" (previous)
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
docker compose build trading-bot
|
||||
docker compose up -d --force-recreate trading-bot
|
||||
```
|
||||
|
||||
Bot automatically restores existing positions from database on startup via Position Manager persistence.
|
||||
|
||||
---
|
||||
|
||||
**Status:** System now prevents duplicate positions on same symbol. Multiple 15-minute signals will be blocked by risk check even if timeframe filter passes.
|
||||
18
instrumentation.ts
Normal file
18
instrumentation.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Next.js Instrumentation Hook
|
||||
*
|
||||
* This file is automatically called when the Next.js server starts
|
||||
* Use it to initialize services that need to run on startup
|
||||
*/
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
console.log('🎯 Server starting - initializing services...')
|
||||
|
||||
// Initialize Position Manager to restore trades from database
|
||||
const { initializePositionManagerOnStartup } = await import('./lib/startup/init-position-manager')
|
||||
await initializePositionManagerOnStartup()
|
||||
|
||||
console.log('✅ Server initialization complete')
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,12 @@ export interface CreateTradeParams {
|
||||
signalStrength?: string
|
||||
timeframe?: string
|
||||
isTestTrade?: boolean
|
||||
// Market context fields
|
||||
expectedEntryPrice?: number
|
||||
fundingRateAtEntry?: number
|
||||
atrAtEntry?: number
|
||||
adxAtEntry?: number
|
||||
volumeAtEntry?: number
|
||||
}
|
||||
|
||||
export interface UpdateTradeStateParams {
|
||||
@@ -56,6 +62,10 @@ export interface UpdateTradeStateParams {
|
||||
unrealizedPnL: number
|
||||
peakPnL: number
|
||||
lastPrice: number
|
||||
maxFavorableExcursion?: number
|
||||
maxAdverseExcursion?: number
|
||||
maxFavorablePrice?: number
|
||||
maxAdversePrice?: number
|
||||
}
|
||||
|
||||
export interface UpdateTradeExitParams {
|
||||
@@ -67,15 +77,23 @@ export interface UpdateTradeExitParams {
|
||||
holdTimeSeconds: number
|
||||
maxDrawdown?: number
|
||||
maxGain?: number
|
||||
// MAE/MFE final values
|
||||
maxFavorableExcursion?: number
|
||||
maxAdverseExcursion?: number
|
||||
maxFavorablePrice?: number
|
||||
maxAdversePrice?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new trade record
|
||||
*/
|
||||
export async function createTrade(params: CreateTradeParams) {
|
||||
const prisma = getPrismaClient()
|
||||
|
||||
try {
|
||||
// Calculate entry slippage if expected price provided
|
||||
let entrySlippagePct: number | undefined
|
||||
if (params.expectedEntryPrice && params.entrySlippage !== undefined) {
|
||||
entrySlippagePct = params.entrySlippage
|
||||
}
|
||||
|
||||
const trade = await prisma.trade.create({
|
||||
data: {
|
||||
positionId: params.positionId,
|
||||
@@ -105,6 +123,13 @@ export async function createTrade(params: CreateTradeParams) {
|
||||
timeframe: params.timeframe,
|
||||
status: 'open',
|
||||
isTestTrade: params.isTestTrade || false,
|
||||
// Market context
|
||||
expectedEntryPrice: params.expectedEntryPrice,
|
||||
entrySlippagePct: entrySlippagePct,
|
||||
fundingRateAtEntry: params.fundingRateAtEntry,
|
||||
atrAtEntry: params.atrAtEntry,
|
||||
adxAtEntry: params.adxAtEntry,
|
||||
volumeAtEntry: params.volumeAtEntry,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -145,6 +170,11 @@ export async function updateTradeExit(params: UpdateTradeExitParams) {
|
||||
holdTimeSeconds: params.holdTimeSeconds,
|
||||
maxDrawdown: params.maxDrawdown,
|
||||
maxGain: params.maxGain,
|
||||
// Save final MAE/MFE values
|
||||
maxFavorableExcursion: params.maxFavorableExcursion,
|
||||
maxAdverseExcursion: params.maxAdverseExcursion,
|
||||
maxFavorablePrice: params.maxFavorablePrice,
|
||||
maxAdversePrice: params.maxAdversePrice,
|
||||
status: 'closed',
|
||||
},
|
||||
})
|
||||
@@ -184,6 +214,10 @@ export async function updateTradeState(params: UpdateTradeStateParams) {
|
||||
unrealizedPnL: params.unrealizedPnL,
|
||||
peakPnL: params.peakPnL,
|
||||
lastPrice: params.lastPrice,
|
||||
maxFavorableExcursion: params.maxFavorableExcursion,
|
||||
maxAdverseExcursion: params.maxAdverseExcursion,
|
||||
maxFavorablePrice: params.maxFavorablePrice,
|
||||
maxAdversePrice: params.maxAdversePrice,
|
||||
lastUpdate: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
@@ -217,6 +251,78 @@ export async function getOpenTrades() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent trade entry time (for cooldown checking)
|
||||
*/
|
||||
export async function getLastTradeTime(): Promise<Date | null> {
|
||||
const prisma = getPrismaClient()
|
||||
|
||||
try {
|
||||
const lastTrade = await prisma.trade.findFirst({
|
||||
orderBy: { entryTime: 'desc' },
|
||||
select: { entryTime: true },
|
||||
})
|
||||
|
||||
return lastTrade?.entryTime || null
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get last trade time:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of trades in the last hour
|
||||
*/
|
||||
export async function getTradesInLastHour(): Promise<number> {
|
||||
const prisma = getPrismaClient()
|
||||
|
||||
try {
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000)
|
||||
|
||||
const count = await prisma.trade.count({
|
||||
where: {
|
||||
entryTime: {
|
||||
gte: oneHourAgo,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return count
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get trades in last hour:', error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total P&L for today
|
||||
*/
|
||||
export async function getTodayPnL(): Promise<number> {
|
||||
const prisma = getPrismaClient()
|
||||
|
||||
try {
|
||||
const startOfDay = new Date()
|
||||
startOfDay.setHours(0, 0, 0, 0)
|
||||
|
||||
const result = await prisma.trade.aggregate({
|
||||
where: {
|
||||
entryTime: {
|
||||
gte: startOfDay,
|
||||
},
|
||||
status: 'closed',
|
||||
},
|
||||
_sum: {
|
||||
realizedPnL: true,
|
||||
},
|
||||
})
|
||||
|
||||
return result._sum.realizedPnL || 0
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get today PnL:', error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add price update for a trade (for tracking max gain/drawdown)
|
||||
*/
|
||||
|
||||
@@ -233,6 +233,31 @@ export class DriftService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get funding rate for a perpetual market
|
||||
* Returns funding rate as percentage (e.g., 0.01 = 1% per 8 hours)
|
||||
*/
|
||||
async getFundingRate(marketIndex: number): Promise<number | null> {
|
||||
this.ensureInitialized()
|
||||
|
||||
try {
|
||||
const perpMarketAccount = this.driftClient!.getPerpMarketAccount(marketIndex)
|
||||
if (!perpMarketAccount) {
|
||||
console.warn(`⚠️ No perp market account found for index ${marketIndex}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Funding rate is stored as a number with 9 decimals (1e9)
|
||||
// Convert to percentage
|
||||
const fundingRate = Number(perpMarketAccount.amm.lastFundingRate) / 1e9
|
||||
|
||||
return fundingRate
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to get funding rate for market ${marketIndex}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account health (margin ratio)
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Handles opening and closing positions with market orders
|
||||
*/
|
||||
|
||||
import { getDriftService } from './client'
|
||||
import { getDriftService, initializeDriftService } from './client'
|
||||
import { getMarketConfig } from '../../config/trading'
|
||||
import BN from 'bn.js'
|
||||
import {
|
||||
@@ -55,6 +55,7 @@ export interface PlaceExitOrdersResult {
|
||||
export interface PlaceExitOrdersOptions {
|
||||
symbol: string
|
||||
positionSizeUSD: number
|
||||
entryPrice: number // CRITICAL: Entry price for calculating position size in base assets
|
||||
tp1Price: number
|
||||
tp2Price: number
|
||||
stopLossPrice: number
|
||||
@@ -222,21 +223,31 @@ export async function placeExitOrders(options: PlaceExitOrdersOptions): Promise<
|
||||
const signatures: string[] = []
|
||||
|
||||
// Helper to compute base asset amount from USD notional and price
|
||||
const usdToBase = (usd: number, price: number) => {
|
||||
const base = usd / price
|
||||
// CRITICAL: Use ENTRY price to calculate position size, not TP price!
|
||||
// This ensures we close the correct percentage of the actual position
|
||||
const usdToBase = (usd: number) => {
|
||||
const base = usd / options.entryPrice // Use entry price for size calculation
|
||||
return Math.floor(base * 1e9) // 9 decimals expected by SDK
|
||||
}
|
||||
|
||||
// Calculate sizes in USD for each TP
|
||||
// CRITICAL FIX: TP2 must be percentage of REMAINING size after TP1, not original size
|
||||
const tp1USD = (options.positionSizeUSD * options.tp1SizePercent) / 100
|
||||
const tp2USD = (options.positionSizeUSD * options.tp2SizePercent) / 100
|
||||
const remainingAfterTP1 = options.positionSizeUSD - tp1USD
|
||||
const tp2USD = (remainingAfterTP1 * options.tp2SizePercent) / 100
|
||||
|
||||
console.log(`📊 Exit order sizes:`)
|
||||
console.log(` TP1: ${options.tp1SizePercent}% of $${options.positionSizeUSD.toFixed(2)} = $${tp1USD.toFixed(2)}`)
|
||||
console.log(` Remaining after TP1: $${remainingAfterTP1.toFixed(2)}`)
|
||||
console.log(` TP2: ${options.tp2SizePercent}% of remaining = $${tp2USD.toFixed(2)}`)
|
||||
console.log(` Runner (if any): $${(remainingAfterTP1 - tp2USD).toFixed(2)}`)
|
||||
|
||||
// For orders that close a long, the order direction should be SHORT (sell)
|
||||
const orderDirection = options.direction === 'long' ? PositionDirection.SHORT : PositionDirection.LONG
|
||||
|
||||
// Place TP1 LIMIT reduce-only
|
||||
if (tp1USD > 0) {
|
||||
const baseAmount = usdToBase(tp1USD, options.tp1Price)
|
||||
const baseAmount = usdToBase(tp1USD)
|
||||
if (baseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) {
|
||||
const orderParams: any = {
|
||||
orderType: OrderType.LIMIT,
|
||||
@@ -258,7 +269,7 @@ export async function placeExitOrders(options: PlaceExitOrdersOptions): Promise<
|
||||
|
||||
// Place TP2 LIMIT reduce-only
|
||||
if (tp2USD > 0) {
|
||||
const baseAmount = usdToBase(tp2USD, options.tp2Price)
|
||||
const baseAmount = usdToBase(tp2USD)
|
||||
if (baseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) {
|
||||
const orderParams: any = {
|
||||
orderType: OrderType.LIMIT,
|
||||
@@ -285,7 +296,7 @@ export async function placeExitOrders(options: PlaceExitOrdersOptions): Promise<
|
||||
// 3. Single TRIGGER_MARKET (default, guaranteed execution)
|
||||
|
||||
const slUSD = options.positionSizeUSD
|
||||
const slBaseAmount = usdToBase(slUSD, options.stopLossPrice)
|
||||
const slBaseAmount = usdToBase(slUSD)
|
||||
|
||||
if (slBaseAmount >= Math.floor(marketConfig.minOrderSize * 1e9)) {
|
||||
const useDualStops = options.useDualStops ?? false
|
||||
@@ -533,7 +544,13 @@ export async function cancelAllOrders(
|
||||
try {
|
||||
console.log(`🗑️ Cancelling all orders for ${symbol}...`)
|
||||
|
||||
const driftService = getDriftService()
|
||||
// Ensure Drift service is initialized
|
||||
let driftService = getDriftService()
|
||||
if (!driftService) {
|
||||
console.log('⚠️ Drift service not initialized, initializing now...')
|
||||
driftService = await initializeDriftService()
|
||||
}
|
||||
|
||||
const driftClient = driftService.getClient()
|
||||
const marketConfig = getMarketConfig(symbol)
|
||||
|
||||
@@ -549,21 +566,22 @@ export async function cancelAllOrders(
|
||||
throw new Error('User account not found')
|
||||
}
|
||||
|
||||
// Filter orders for this market
|
||||
// Filter orders for this market (check for active orders, not just status)
|
||||
// Note: Trigger orders may have different status values, so we check for non-zero orderId
|
||||
const ordersToCancel = userAccount.orders.filter(
|
||||
(order: any) =>
|
||||
order.marketIndex === marketConfig.driftMarketIndex &&
|
||||
order.status === 0 // 0 = Open status
|
||||
order.orderId > 0 // Active orders have orderId > 0
|
||||
)
|
||||
|
||||
|
||||
if (ordersToCancel.length === 0) {
|
||||
console.log('✅ No open orders to cancel')
|
||||
return { success: true, cancelledCount: 0 }
|
||||
}
|
||||
|
||||
console.log(`📋 Found ${ordersToCancel.length} open orders to cancel (including trigger orders)`)
|
||||
|
||||
console.log(`📋 Found ${ordersToCancel.length} open orders to cancel`)
|
||||
|
||||
// Cancel all orders for this market
|
||||
// Cancel all orders for this market (cancels all types: LIMIT, TRIGGER_MARKET, TRIGGER_LIMIT)
|
||||
const txSig = await driftClient.cancelOrders(
|
||||
undefined, // Cancel by market type
|
||||
marketConfig.driftMarketIndex,
|
||||
|
||||
33
lib/startup/init-position-manager.ts
Normal file
33
lib/startup/init-position-manager.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Position Manager Startup Initialization
|
||||
*
|
||||
* Ensures Position Manager starts monitoring on bot startup
|
||||
* This prevents orphaned trades when the bot restarts
|
||||
*/
|
||||
|
||||
import { getInitializedPositionManager } from '../trading/position-manager'
|
||||
|
||||
let initStarted = false
|
||||
|
||||
export async function initializePositionManagerOnStartup() {
|
||||
if (initStarted) {
|
||||
return
|
||||
}
|
||||
|
||||
initStarted = true
|
||||
|
||||
console.log('🚀 Initializing Position Manager on startup...')
|
||||
|
||||
try {
|
||||
const manager = await getInitializedPositionManager()
|
||||
const status = manager.getStatus()
|
||||
|
||||
console.log(`✅ Position Manager ready - ${status.activeTradesCount} active trades`)
|
||||
|
||||
if (status.activeTradesCount > 0) {
|
||||
console.log(`📊 Monitoring: ${status.symbols.join(', ')}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize Position Manager on startup:', error)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
import { getDriftService } from '../drift/client'
|
||||
import { closePosition } from '../drift/orders'
|
||||
import { getPythPriceMonitor, PriceUpdate } from '../pyth/price-monitor'
|
||||
import { getMergedConfig, TradingConfig } from '../../config/trading'
|
||||
import { getMergedConfig, TradingConfig, getMarketConfig } from '../../config/trading'
|
||||
import { updateTradeExit, updateTradeState, getOpenTrades } from '../database/trades'
|
||||
|
||||
export interface ActiveTrade {
|
||||
@@ -42,6 +42,13 @@ export interface ActiveTrade {
|
||||
peakPnL: number
|
||||
peakPrice: number // Track highest price reached (for trailing)
|
||||
|
||||
// MAE/MFE tracking (Maximum Adverse/Favorable Excursion)
|
||||
maxFavorableExcursion: number // Best profit % reached
|
||||
maxAdverseExcursion: number // Worst drawdown % reached
|
||||
maxFavorablePrice: number // Best price hit
|
||||
maxAdversePrice: number // Worst price hit
|
||||
lastDbMetricsUpdate: number // Last time we updated MAE/MFE in DB (throttle to 5s)
|
||||
|
||||
// Monitoring
|
||||
priceCheckCount: number
|
||||
lastPrice: number
|
||||
@@ -110,6 +117,11 @@ export class PositionManager {
|
||||
unrealizedPnL: pmState?.unrealizedPnL ?? 0,
|
||||
peakPnL: pmState?.peakPnL ?? 0,
|
||||
peakPrice: pmState?.peakPrice ?? dbTrade.entryPrice,
|
||||
maxFavorableExcursion: pmState?.maxFavorableExcursion ?? 0,
|
||||
maxAdverseExcursion: pmState?.maxAdverseExcursion ?? 0,
|
||||
maxFavorablePrice: pmState?.maxFavorablePrice ?? dbTrade.entryPrice,
|
||||
maxAdversePrice: pmState?.maxAdversePrice ?? dbTrade.entryPrice,
|
||||
lastDbMetricsUpdate: Date.now(),
|
||||
priceCheckCount: 0,
|
||||
lastPrice: pmState?.lastPrice ?? dbTrade.entryPrice,
|
||||
lastUpdateTime: Date.now(),
|
||||
@@ -141,8 +153,8 @@ export class PositionManager {
|
||||
|
||||
this.activeTrades.set(trade.id, trade)
|
||||
|
||||
// Save initial state to database
|
||||
await this.saveTradeState(trade)
|
||||
// Note: Initial state is saved by the API endpoint that creates the trade
|
||||
// We don't save here to avoid race condition (trade may not be in DB yet)
|
||||
|
||||
console.log(`✅ Trade added. Active trades: ${this.activeTrades.size}`)
|
||||
|
||||
@@ -155,10 +167,23 @@ export class PositionManager {
|
||||
/**
|
||||
* Remove a trade from monitoring
|
||||
*/
|
||||
removeTrade(tradeId: string): void {
|
||||
async removeTrade(tradeId: string): Promise<void> {
|
||||
const trade = this.activeTrades.get(tradeId)
|
||||
if (trade) {
|
||||
console.log(`🗑️ Removing trade: ${trade.symbol}`)
|
||||
|
||||
// Cancel all orders for this symbol (cleanup orphaned orders)
|
||||
try {
|
||||
const { cancelAllOrders } = await import('../drift/orders')
|
||||
const cancelResult = await cancelAllOrders(trade.symbol)
|
||||
if (cancelResult.success && cancelResult.cancelledCount! > 0) {
|
||||
console.log(`✅ Cancelled ${cancelResult.cancelledCount} orphaned orders`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to cancel orders during trade removal:', error)
|
||||
// Continue with removal even if cancel fails
|
||||
}
|
||||
|
||||
this.activeTrades.delete(tradeId)
|
||||
|
||||
// Stop monitoring if no more trades
|
||||
@@ -258,6 +283,119 @@ export class PositionManager {
|
||||
trade: ActiveTrade,
|
||||
currentPrice: number
|
||||
): Promise<void> {
|
||||
// CRITICAL: First check if on-chain position still exists
|
||||
// (may have been closed by TP/SL orders without us knowing)
|
||||
try {
|
||||
const driftService = getDriftService()
|
||||
|
||||
// Skip position verification if Drift service isn't initialized yet
|
||||
// (happens briefly after restart while service initializes)
|
||||
if (!driftService || !(driftService as any).isInitialized) {
|
||||
// Service still initializing, skip this check cycle
|
||||
return
|
||||
}
|
||||
|
||||
const marketConfig = getMarketConfig(trade.symbol)
|
||||
const position = await driftService.getPosition(marketConfig.driftMarketIndex)
|
||||
|
||||
if (position === null || position.size === 0) {
|
||||
// Position closed externally (by on-chain TP/SL order)
|
||||
console.log(`⚠️ Position ${trade.symbol} was closed externally (by on-chain order)`)
|
||||
|
||||
// Save currentSize before it becomes 0
|
||||
const sizeBeforeClosure = trade.currentSize
|
||||
|
||||
// Determine exit reason based on price
|
||||
let exitReason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' = 'SL'
|
||||
|
||||
if (trade.direction === 'long') {
|
||||
if (currentPrice >= trade.tp2Price) {
|
||||
exitReason = 'TP2'
|
||||
} else if (currentPrice >= trade.tp1Price) {
|
||||
exitReason = 'TP1'
|
||||
} else if (currentPrice <= trade.stopLossPrice) {
|
||||
exitReason = 'HARD_SL' // Assume hard stop if below SL
|
||||
}
|
||||
} else {
|
||||
// Short
|
||||
if (currentPrice <= trade.tp2Price) {
|
||||
exitReason = 'TP2'
|
||||
} else if (currentPrice <= trade.tp1Price) {
|
||||
exitReason = 'TP1'
|
||||
} else if (currentPrice >= trade.stopLossPrice) {
|
||||
exitReason = 'HARD_SL' // Assume hard stop if above SL
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate final P&L using size BEFORE closure
|
||||
const profitPercent = this.calculateProfitPercent(
|
||||
trade.entryPrice,
|
||||
currentPrice,
|
||||
trade.direction
|
||||
)
|
||||
const accountPnL = profitPercent * trade.leverage
|
||||
const realizedPnL = (sizeBeforeClosure * accountPnL) / 100
|
||||
|
||||
// Update database
|
||||
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
|
||||
try {
|
||||
await updateTradeExit({
|
||||
positionId: trade.positionId,
|
||||
exitPrice: currentPrice,
|
||||
exitReason,
|
||||
realizedPnL,
|
||||
exitOrderTx: 'ON_CHAIN_ORDER',
|
||||
holdTimeSeconds,
|
||||
maxDrawdown: 0,
|
||||
maxGain: trade.peakPnL,
|
||||
// Save final MAE/MFE values
|
||||
maxFavorableExcursion: trade.maxFavorableExcursion,
|
||||
maxAdverseExcursion: trade.maxAdverseExcursion,
|
||||
maxFavorablePrice: trade.maxFavorablePrice,
|
||||
maxAdversePrice: trade.maxAdversePrice,
|
||||
})
|
||||
console.log(`💾 External closure recorded: ${exitReason} at $${currentPrice} | P&L: $${realizedPnL.toFixed(2)} | MFE: ${trade.maxFavorableExcursion.toFixed(2)}% | MAE: ${trade.maxAdverseExcursion.toFixed(2)}%`)
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save external closure:', dbError)
|
||||
}
|
||||
|
||||
// Remove from monitoring
|
||||
await this.removeTrade(trade.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Position exists but size mismatch (partial close by TP1 or TP2?)
|
||||
if (position.size < trade.currentSize * 0.95) { // 5% tolerance
|
||||
console.log(`⚠️ Position size mismatch: expected ${trade.currentSize}, got ${position.size}`)
|
||||
|
||||
// Determine if this was TP1 or TP2 based on size
|
||||
const remainingPercent = (position.size / trade.positionSize) * 100
|
||||
|
||||
if (!trade.tp1Hit && remainingPercent < 30) {
|
||||
// First partial close, likely TP1 (should leave ~25%)
|
||||
trade.tp1Hit = true
|
||||
console.log(`✅ TP1 detected on-chain (${remainingPercent.toFixed(1)}% remaining)`)
|
||||
} else if (trade.tp1Hit && !trade.tp2Hit && remainingPercent < 10) {
|
||||
// Second partial close, likely TP2 (should leave ~5% runner)
|
||||
trade.tp2Hit = true
|
||||
console.log(`✅ TP2 detected on-chain (${remainingPercent.toFixed(1)}% runner remaining)`)
|
||||
}
|
||||
|
||||
// Update current size to match reality
|
||||
trade.currentSize = position.size * (trade.positionSize / trade.currentSize) // Convert to USD
|
||||
await this.saveTradeState(trade)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// If we can't check position, continue with monitoring (don't want to false-positive)
|
||||
// This can happen briefly during startup while Drift service initializes
|
||||
if ((error as Error).message?.includes('not initialized')) {
|
||||
// Silent - expected during initialization
|
||||
} else {
|
||||
console.error(`⚠️ Could not verify on-chain position for ${trade.symbol}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Update trade data
|
||||
trade.lastPrice = currentPrice
|
||||
trade.lastUpdateTime = Date.now()
|
||||
@@ -273,6 +411,23 @@ export class PositionManager {
|
||||
const accountPnL = profitPercent * trade.leverage
|
||||
trade.unrealizedPnL = (trade.currentSize * profitPercent) / 100
|
||||
|
||||
// Track MAE/MFE (Maximum Adverse/Favorable Excursion)
|
||||
if (profitPercent > trade.maxFavorableExcursion) {
|
||||
trade.maxFavorableExcursion = profitPercent
|
||||
trade.maxFavorablePrice = currentPrice
|
||||
}
|
||||
|
||||
if (profitPercent < trade.maxAdverseExcursion) {
|
||||
trade.maxAdverseExcursion = profitPercent
|
||||
trade.maxAdversePrice = currentPrice
|
||||
}
|
||||
|
||||
// Update MAE/MFE in database (throttled to every 5 seconds to avoid spam)
|
||||
if (Date.now() - trade.lastDbMetricsUpdate > 5000) {
|
||||
await this.updateTradeMetrics(trade)
|
||||
trade.lastDbMetricsUpdate = Date.now()
|
||||
}
|
||||
|
||||
// Track peak P&L
|
||||
if (trade.unrealizedPnL > trade.peakPnL) {
|
||||
trade.peakPnL = trade.unrealizedPnL
|
||||
@@ -360,7 +515,7 @@ export class PositionManager {
|
||||
}
|
||||
|
||||
// 5. Take profit 2 (remaining position)
|
||||
if (trade.tp1Hit && this.shouldTakeProfit2(currentPrice, trade)) {
|
||||
if (trade.tp1Hit && !trade.tp2Hit && this.shouldTakeProfit2(currentPrice, trade)) {
|
||||
console.log(`🎊 TP2 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
|
||||
|
||||
// Calculate how much to close based on TP2 size percent
|
||||
@@ -466,15 +621,20 @@ export class PositionManager {
|
||||
holdTimeSeconds,
|
||||
maxDrawdown: 0, // TODO: Track this
|
||||
maxGain: trade.peakPnL,
|
||||
// Save final MAE/MFE values
|
||||
maxFavorableExcursion: trade.maxFavorableExcursion,
|
||||
maxAdverseExcursion: trade.maxAdverseExcursion,
|
||||
maxFavorablePrice: trade.maxFavorablePrice,
|
||||
maxAdversePrice: trade.maxAdversePrice,
|
||||
})
|
||||
console.log('💾 Trade saved to database')
|
||||
console.log('💾 Trade saved to database with MAE: ' + trade.maxAdverseExcursion.toFixed(2) + '% | MFE: ' + trade.maxFavorableExcursion.toFixed(2) + '%')
|
||||
} catch (dbError) {
|
||||
console.error('❌ Failed to save trade exit to database:', dbError)
|
||||
// Don't fail the close if database fails
|
||||
}
|
||||
}
|
||||
|
||||
this.removeTrade(trade.id)
|
||||
await this.removeTrade(trade.id)
|
||||
console.log(`✅ Position closed | P&L: $${trade.realizedPnL.toFixed(2)} | Reason: ${reason}`)
|
||||
} else {
|
||||
// Partial close (TP1)
|
||||
@@ -587,6 +747,10 @@ export class PositionManager {
|
||||
unrealizedPnL: trade.unrealizedPnL,
|
||||
peakPnL: trade.peakPnL,
|
||||
lastPrice: trade.lastPrice,
|
||||
maxFavorableExcursion: trade.maxFavorableExcursion,
|
||||
maxAdverseExcursion: trade.maxAdverseExcursion,
|
||||
maxFavorablePrice: trade.maxFavorablePrice,
|
||||
maxAdversePrice: trade.maxAdversePrice,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to save trade state:', error)
|
||||
@@ -612,6 +776,29 @@ export class PositionManager {
|
||||
symbols,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update MAE/MFE metrics in database (throttled)
|
||||
*/
|
||||
private async updateTradeMetrics(trade: ActiveTrade): Promise<void> {
|
||||
try {
|
||||
const { getPrismaClient } = await import('../database/trades')
|
||||
const prisma = getPrismaClient()
|
||||
|
||||
await prisma.trade.update({
|
||||
where: { id: trade.id },
|
||||
data: {
|
||||
maxFavorableExcursion: trade.maxFavorableExcursion,
|
||||
maxAdverseExcursion: trade.maxAdverseExcursion,
|
||||
maxFavorablePrice: trade.maxFavorablePrice,
|
||||
maxAdversePrice: trade.maxAdversePrice,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
// Silent failure to avoid disrupting monitoring loop
|
||||
console.error('Failed to update trade metrics:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Trade" ADD COLUMN "adxAtEntry" DOUBLE PRECISION,
|
||||
ADD COLUMN "atrAtEntry" DOUBLE PRECISION,
|
||||
ADD COLUMN "basisAtEntry" DOUBLE PRECISION,
|
||||
ADD COLUMN "entrySlippagePct" DOUBLE PRECISION,
|
||||
ADD COLUMN "exitSlippagePct" DOUBLE PRECISION,
|
||||
ADD COLUMN "expectedEntryPrice" DOUBLE PRECISION,
|
||||
ADD COLUMN "expectedExitPrice" DOUBLE PRECISION,
|
||||
ADD COLUMN "fundingRateAtEntry" DOUBLE PRECISION,
|
||||
ADD COLUMN "hardSlFilled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "maxAdverseExcursion" DOUBLE PRECISION,
|
||||
ADD COLUMN "maxAdversePrice" DOUBLE PRECISION,
|
||||
ADD COLUMN "maxFavorableExcursion" DOUBLE PRECISION,
|
||||
ADD COLUMN "maxFavorablePrice" DOUBLE PRECISION,
|
||||
ADD COLUMN "slFillPrice" DOUBLE PRECISION,
|
||||
ADD COLUMN "softSlFilled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "timeToSl" INTEGER,
|
||||
ADD COLUMN "timeToTp1" INTEGER,
|
||||
ADD COLUMN "timeToTp2" INTEGER,
|
||||
ADD COLUMN "tp1FillPrice" DOUBLE PRECISION,
|
||||
ADD COLUMN "tp1Filled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "tp2FillPrice" DOUBLE PRECISION,
|
||||
ADD COLUMN "tp2Filled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "volumeAtEntry" DOUBLE PRECISION;
|
||||
@@ -49,6 +49,39 @@ model Trade {
|
||||
maxDrawdown Float? // Peak to valley during trade
|
||||
maxGain Float? // Peak gain reached
|
||||
|
||||
// MAE/MFE Analysis (Maximum Adverse/Favorable Excursion)
|
||||
maxFavorableExcursion Float? // Best profit % reached during trade
|
||||
maxAdverseExcursion Float? // Worst drawdown % during trade
|
||||
maxFavorablePrice Float? // Best price hit (direction-aware)
|
||||
maxAdversePrice Float? // Worst price hit (direction-aware)
|
||||
|
||||
// Exit details - which levels actually filled
|
||||
tp1Filled Boolean @default(false)
|
||||
tp2Filled Boolean @default(false)
|
||||
softSlFilled Boolean @default(false)
|
||||
hardSlFilled Boolean @default(false)
|
||||
tp1FillPrice Float?
|
||||
tp2FillPrice Float?
|
||||
slFillPrice Float?
|
||||
|
||||
// Timing metrics
|
||||
timeToTp1 Int? // Seconds from entry to TP1 fill
|
||||
timeToTp2 Int? // Seconds from entry to TP2 fill
|
||||
timeToSl Int? // Seconds from entry to SL hit
|
||||
|
||||
// Market context at entry
|
||||
atrAtEntry Float? // ATR% when trade opened
|
||||
adxAtEntry Float? // ADX trend strength (0-50)
|
||||
volumeAtEntry Float? // Volume relative to MA
|
||||
fundingRateAtEntry Float? // Perp funding rate at entry
|
||||
basisAtEntry Float? // Perp-spot basis at entry
|
||||
|
||||
// Slippage tracking
|
||||
expectedEntryPrice Float? // Target entry from signal
|
||||
entrySlippagePct Float? // Actual slippage %
|
||||
expectedExitPrice Float? // Which TP/SL should have hit
|
||||
exitSlippagePct Float? // Exit slippage %
|
||||
|
||||
// Order signatures
|
||||
entryOrderTx String
|
||||
tp1OrderTx String?
|
||||
|
||||
15
scripts/test-analytics.sh
Executable file
15
scripts/test-analytics.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Test TP/SL Optimization Analytics Endpoint
|
||||
# Usage: ./scripts/test-analytics.sh
|
||||
|
||||
echo "🔍 Testing TP/SL Optimization Analytics..."
|
||||
echo ""
|
||||
|
||||
RESPONSE=$(curl -s http://localhost:3001/api/analytics/tp-sl-optimization)
|
||||
|
||||
# Pretty print JSON response
|
||||
echo "$RESPONSE" | jq '.' || echo "$RESPONSE"
|
||||
|
||||
echo ""
|
||||
echo "✅ Test complete"
|
||||
@@ -99,6 +99,350 @@ async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
print(f"❌ Error: {e}", flush=True)
|
||||
await update.message.reply_text(f"❌ Error: {str(e)}")
|
||||
|
||||
async def scale_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Handle /scale command - add to existing position and adjust TP/SL"""
|
||||
|
||||
# Only process from YOUR chat
|
||||
if update.message.chat_id != ALLOWED_CHAT_ID:
|
||||
await update.message.reply_text("❌ Unauthorized")
|
||||
return
|
||||
|
||||
print(f"📈 /scale command received", flush=True)
|
||||
|
||||
try:
|
||||
# First, get the current open position
|
||||
pos_response = requests.get(
|
||||
f"{TRADING_BOT_URL}/api/trading/positions",
|
||||
headers={'Authorization': f'Bearer {API_SECRET_KEY}'},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if not pos_response.ok:
|
||||
await update.message.reply_text(f"❌ Error fetching positions: {pos_response.status_code}")
|
||||
return
|
||||
|
||||
pos_data = pos_response.json()
|
||||
positions = pos_data.get('positions', [])
|
||||
|
||||
if not positions:
|
||||
await update.message.reply_text("❌ No open positions to scale")
|
||||
return
|
||||
|
||||
if len(positions) > 1:
|
||||
await update.message.reply_text("❌ Multiple positions open. Please close extras first.")
|
||||
return
|
||||
|
||||
position = positions[0]
|
||||
trade_id = position['id']
|
||||
|
||||
# Determine scale percent from command argument
|
||||
scale_percent = 50 # Default
|
||||
if context.args and len(context.args) > 0:
|
||||
try:
|
||||
scale_percent = int(context.args[0])
|
||||
if scale_percent < 10 or scale_percent > 200:
|
||||
await update.message.reply_text("❌ Scale percent must be between 10 and 200")
|
||||
return
|
||||
except ValueError:
|
||||
await update.message.reply_text("❌ Invalid scale percent. Usage: /scale [percent]")
|
||||
return
|
||||
|
||||
# Send scaling request
|
||||
response = requests.post(
|
||||
f"{TRADING_BOT_URL}/api/trading/scale-position",
|
||||
headers={'Authorization': f'Bearer {API_SECRET_KEY}'},
|
||||
json={'tradeId': trade_id, 'scalePercent': scale_percent},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
print(f"📥 API Response: {response.status_code}", flush=True)
|
||||
|
||||
if not response.ok:
|
||||
data = response.json()
|
||||
await update.message.reply_text(f"❌ Error: {data.get('message', 'Unknown error')}")
|
||||
return
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get('success'):
|
||||
await update.message.reply_text(f"❌ {data.get('message', 'Failed to scale position')}")
|
||||
return
|
||||
|
||||
# Build success message
|
||||
message = f"✅ *Position Scaled by {scale_percent}%*\n\n"
|
||||
message += f"*{position['symbol']} {position['direction'].upper()}*\n\n"
|
||||
message += f"*Entry Price:*\n"
|
||||
message += f" Old: ${data['oldEntry']:.2f}\n"
|
||||
message += f" New: ${data['newEntry']:.2f}\n\n"
|
||||
message += f"*Position Size:*\n"
|
||||
message += f" Old: ${data['oldSize']:.0f}\n"
|
||||
message += f" New: ${data['newSize']:.0f}\n\n"
|
||||
message += f"*New Targets:*\n"
|
||||
message += f" TP1: ${data['newTP1']:.2f}\n"
|
||||
message += f" TP2: ${data['newTP2']:.2f}\n"
|
||||
message += f" SL: ${data['newSL']:.2f}\n\n"
|
||||
message += f"🎯 All TP/SL orders updated!"
|
||||
|
||||
await update.message.reply_text(message, parse_mode='Markdown')
|
||||
|
||||
print(f"✅ Position scaled: {scale_percent}%", flush=True)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}", flush=True)
|
||||
await update.message.reply_text(f"❌ Error: {str(e)}")
|
||||
|
||||
async def reduce_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Handle /reduce command - take partial profits and adjust TP/SL"""
|
||||
|
||||
# Only process from YOUR chat
|
||||
if update.message.chat_id != ALLOWED_CHAT_ID:
|
||||
await update.message.reply_text("❌ Unauthorized")
|
||||
return
|
||||
|
||||
print(f"📉 /reduce command received", flush=True)
|
||||
|
||||
try:
|
||||
# First, get the current open position
|
||||
pos_response = requests.get(
|
||||
f"{TRADING_BOT_URL}/api/trading/positions",
|
||||
headers={'Authorization': f'Bearer {API_SECRET_KEY}'},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if not pos_response.ok:
|
||||
await update.message.reply_text(f"❌ Error fetching positions: {pos_response.status_code}")
|
||||
return
|
||||
|
||||
pos_data = pos_response.json()
|
||||
positions = pos_data.get('positions', [])
|
||||
|
||||
if not positions:
|
||||
await update.message.reply_text("❌ No open positions to reduce")
|
||||
return
|
||||
|
||||
if len(positions) > 1:
|
||||
await update.message.reply_text("❌ Multiple positions open. Please close extras first.")
|
||||
return
|
||||
|
||||
position = positions[0]
|
||||
trade_id = position['id']
|
||||
|
||||
# Determine reduce percent from command argument
|
||||
reduce_percent = 50 # Default
|
||||
if context.args and len(context.args) > 0:
|
||||
try:
|
||||
reduce_percent = int(context.args[0])
|
||||
if reduce_percent < 10 or reduce_percent > 100:
|
||||
await update.message.reply_text("❌ Reduce percent must be between 10 and 100")
|
||||
return
|
||||
except ValueError:
|
||||
await update.message.reply_text("❌ Invalid reduce percent. Usage: /reduce [percent]")
|
||||
return
|
||||
|
||||
# Send reduce request
|
||||
response = requests.post(
|
||||
f"{TRADING_BOT_URL}/api/trading/reduce-position",
|
||||
headers={'Authorization': f'Bearer {API_SECRET_KEY}'},
|
||||
json={'tradeId': trade_id, 'reducePercent': reduce_percent},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
print(f"📥 API Response: {response.status_code}", flush=True)
|
||||
|
||||
if not response.ok:
|
||||
data = response.json()
|
||||
await update.message.reply_text(f"❌ Error: {data.get('message', 'Unknown error')}")
|
||||
return
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get('success'):
|
||||
await update.message.reply_text(f"❌ {data.get('message', 'Failed to reduce position')}")
|
||||
return
|
||||
|
||||
# Build success message
|
||||
message = f"✅ *Position Reduced by {reduce_percent}%*\n\n"
|
||||
message += f"*{position['symbol']} {position['direction'].upper()}*\n\n"
|
||||
message += f"*Closed:*\n"
|
||||
message += f" Size: ${data['closedSize']:.0f}\n"
|
||||
message += f" Price: ${data['closePrice']:.2f}\n"
|
||||
message += f" P&L: ${data['realizedPnL']:.2f}\n\n"
|
||||
message += f"*Remaining:*\n"
|
||||
message += f" Size: ${data['remainingSize']:.0f}\n"
|
||||
message += f" Entry: ${position['entryPrice']:.2f}\n\n"
|
||||
message += f"*Updated Targets:*\n"
|
||||
message += f" TP1: ${data['newTP1']:.2f}\n"
|
||||
message += f" TP2: ${data['newTP2']:.2f}\n"
|
||||
message += f" SL: ${data['newSL']:.2f}\n\n"
|
||||
message += f"🎯 TP/SL orders updated for remaining size!"
|
||||
|
||||
await update.message.reply_text(message, parse_mode='Markdown')
|
||||
|
||||
print(f"✅ Position reduced: {reduce_percent}%", flush=True)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}", flush=True)
|
||||
await update.message.reply_text(f"❌ Error: {str(e)}")
|
||||
|
||||
async def close_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Handle /close command - close entire position and cancel all orders"""
|
||||
|
||||
# Only process from YOUR chat
|
||||
if update.message.chat_id != ALLOWED_CHAT_ID:
|
||||
await update.message.reply_text("❌ Unauthorized")
|
||||
return
|
||||
|
||||
print(f"🔴 /close command received", flush=True)
|
||||
|
||||
try:
|
||||
# First, get the current open position
|
||||
pos_response = requests.get(
|
||||
f"{TRADING_BOT_URL}/api/trading/positions",
|
||||
headers={'Authorization': f'Bearer {API_SECRET_KEY}'},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if not pos_response.ok:
|
||||
await update.message.reply_text(f"❌ Error fetching positions: {pos_response.status_code}")
|
||||
return
|
||||
|
||||
pos_data = pos_response.json()
|
||||
positions = pos_data.get('positions', [])
|
||||
|
||||
if not positions:
|
||||
await update.message.reply_text("❌ No open positions to close")
|
||||
return
|
||||
|
||||
if len(positions) > 1:
|
||||
await update.message.reply_text("❌ Multiple positions open. Specify symbol or use /reduce")
|
||||
return
|
||||
|
||||
position = positions[0]
|
||||
symbol = position['symbol']
|
||||
direction = position['direction'].upper()
|
||||
entry = position['entryPrice']
|
||||
size = position['currentSize']
|
||||
|
||||
# Close position at market (100%)
|
||||
response = requests.post(
|
||||
f"{TRADING_BOT_URL}/api/trading/close",
|
||||
headers={'Authorization': f'Bearer {API_SECRET_KEY}'},
|
||||
json={'symbol': symbol, 'percentToClose': 100},
|
||||
timeout=30
|
||||
)
|
||||
|
||||
print(f"📥 API Response: {response.status_code}", flush=True)
|
||||
|
||||
if not response.ok:
|
||||
data = response.json()
|
||||
await update.message.reply_text(f"❌ Error: {data.get('message', 'Unknown error')}")
|
||||
return
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get('success'):
|
||||
await update.message.reply_text(f"❌ {data.get('message', 'Failed to close position')}")
|
||||
return
|
||||
|
||||
# Build success message
|
||||
close_price = data.get('closePrice', 0)
|
||||
realized_pnl = data.get('realizedPnL', 0)
|
||||
|
||||
emoji = "💚" if realized_pnl > 0 else "❤️" if realized_pnl < 0 else "💛"
|
||||
|
||||
message = f"{emoji} *Position Closed*\n\n"
|
||||
message += f"*{symbol} {direction}*\n\n"
|
||||
message += f"*Entry:* ${entry:.4f}\n"
|
||||
message += f"*Exit:* ${close_price:.4f}\n"
|
||||
message += f"*Size:* ${size:.2f}\n\n"
|
||||
message += f"*P&L:* ${realized_pnl:.2f}\n\n"
|
||||
message += f"✅ Position closed at market\n"
|
||||
message += f"✅ All TP/SL orders cancelled"
|
||||
|
||||
await update.message.reply_text(message, parse_mode='Markdown')
|
||||
|
||||
print(f"✅ Position closed: {symbol} | P&L: ${realized_pnl:.2f}", flush=True)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}", flush=True)
|
||||
await update.message.reply_text(f"❌ Error: {str(e)}")
|
||||
|
||||
async def validate_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Handle /validate command - check position consistency"""
|
||||
|
||||
# Only process from YOUR chat
|
||||
if update.message.chat_id != ALLOWED_CHAT_ID:
|
||||
await update.message.reply_text("❌ Unauthorized")
|
||||
return
|
||||
|
||||
print(f"🔍 /validate command received", flush=True)
|
||||
|
||||
try:
|
||||
# Fetch validation from trading bot API
|
||||
response = requests.post(
|
||||
f"{TRADING_BOT_URL}/api/trading/validate-positions",
|
||||
headers={'Authorization': f'Bearer {API_SECRET_KEY}'},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
print(f"📥 API Response: {response.status_code}", flush=True)
|
||||
|
||||
if not response.ok:
|
||||
await update.message.reply_text(f"❌ Error validating positions: {response.status_code}")
|
||||
return
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get('success'):
|
||||
await update.message.reply_text("❌ Failed to validate positions")
|
||||
return
|
||||
|
||||
# Get summary
|
||||
summary = data.get('summary', {})
|
||||
config = data.get('config', {})
|
||||
positions = data.get('positions', [])
|
||||
|
||||
if not positions:
|
||||
await update.message.reply_text("📊 *No positions to validate*\n\nAll clear!", parse_mode='Markdown')
|
||||
return
|
||||
|
||||
# Build validation report
|
||||
message = "🔍 *Position Validation Report*\n\n"
|
||||
message += f"*Current Settings:*\n"
|
||||
message += f" Leverage: {config.get('leverage')}x\n"
|
||||
message += f" Position Size: ${config.get('positionSize')}\n"
|
||||
message += f" TP1: {config.get('tp1Percent')}%\n"
|
||||
message += f" TP2: {config.get('tp2Percent')}%\n"
|
||||
message += f" SL: {config.get('stopLossPercent')}%\n\n"
|
||||
|
||||
message += f"*Summary:*\n"
|
||||
message += f" Total: {summary.get('totalPositions')}\n"
|
||||
message += f" ✅ Valid: {summary.get('validPositions')}\n"
|
||||
message += f" ⚠️ Issues: {summary.get('positionsWithIssues')}\n\n"
|
||||
|
||||
# Show each position with issues
|
||||
for pos in positions:
|
||||
if not pos['isValid']:
|
||||
message += f"*{pos['symbol']} {pos['direction'].upper()}*\n"
|
||||
message += f"Entry: ${pos['entryPrice']:.4f}\n"
|
||||
|
||||
for issue in pos['issues']:
|
||||
emoji = "❌" if issue['type'] == 'error' else "⚠️"
|
||||
message += f"{emoji} {issue['message']}\n"
|
||||
|
||||
message += "\n"
|
||||
|
||||
if summary.get('validPositions') == summary.get('totalPositions'):
|
||||
message = "✅ *All positions valid!*\n\n" + message
|
||||
|
||||
await update.message.reply_text(message, parse_mode='Markdown')
|
||||
|
||||
print(f"✅ Validation sent", flush=True)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}", flush=True)
|
||||
await update.message.reply_text(f"❌ Error: {str(e)}")
|
||||
|
||||
async def trade_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Handle trade commands like /buySOL, /sellBTC, etc."""
|
||||
|
||||
@@ -161,6 +505,9 @@ def main():
|
||||
print(f"🤖 Trading Bot: {TRADING_BOT_URL}", flush=True)
|
||||
print(f"\n✅ Commands:", flush=True)
|
||||
print(f" /status - Show open positions", flush=True)
|
||||
print(f" /validate - Validate positions against settings", flush=True)
|
||||
print(f" /scale [percent] - Scale position (default 50%)", flush=True)
|
||||
print(f" /reduce [percent] - Take partial profits (default 50%)", flush=True)
|
||||
print(f" /buySOL, /sellSOL", flush=True)
|
||||
print(f" /buyBTC, /sellBTC", flush=True)
|
||||
print(f" /buyETH, /sellETH", flush=True)
|
||||
@@ -170,6 +517,10 @@ def main():
|
||||
|
||||
# Add command handlers
|
||||
application.add_handler(CommandHandler("status", status_command))
|
||||
application.add_handler(CommandHandler("close", close_command))
|
||||
application.add_handler(CommandHandler("validate", validate_command))
|
||||
application.add_handler(CommandHandler("scale", scale_command))
|
||||
application.add_handler(CommandHandler("reduce", reduce_command))
|
||||
application.add_handler(CommandHandler("buySOL", trade_command))
|
||||
application.add_handler(CommandHandler("sellSOL", trade_command))
|
||||
application.add_handler(CommandHandler("buyBTC", trade_command))
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "3371ad7c-0866-4161-90a4-f251de4aceb8",
|
||||
"path": "tradingview-bot-v4",
|
||||
"options": {}
|
||||
},
|
||||
"id": "35b54214-9761-49dc-97b6-df39543f0a7b",
|
||||
"id": "c762618c-fac7-4689-9356-8a78fc7160a8",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
-840,
|
||||
660
|
||||
-980,
|
||||
680
|
||||
],
|
||||
"webhookId": "3371ad7c-0866-4161-90a4-f251de4aceb8"
|
||||
"webhookId": "tradingview-bot-v4"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
@@ -27,27 +27,48 @@
|
||||
},
|
||||
{
|
||||
"name": "symbol",
|
||||
"stringValue": "={{ ($json.body || '').toString().match(/\\bSOL\\b/i) ? 'SOL-PERP' : (($json.body || '').toString().match(/\\bBTC\\b/i) ? 'BTC-PERP' : (($json.body || '').toString().match(/\\bETH\\b/i) ? 'ETH-PERP' : 'SOL-PERP')) }}"
|
||||
"stringValue": "={{ $json.body.match(/\\bSOL\\b/i) ? 'SOL-PERP' : ($json.body.match(/\\bBTC\\b/i) ? 'BTC-PERP' : ($json.body.match(/\\bETH\\b/i) ? 'ETH-PERP' : 'SOL-PERP')) }}"
|
||||
},
|
||||
{
|
||||
"name": "direction",
|
||||
"stringValue": "={{ ($json.body || '').toString().match(/\\b(sell|short)\\b/i) ? 'short' : 'long' }}"
|
||||
"stringValue": "={{ $json.body.match(/\\b(sell|short)\\b/i) ? 'short' : 'long' }}"
|
||||
},
|
||||
{
|
||||
"name": "timeframe",
|
||||
"stringValue": "5"
|
||||
"stringValue": "={{ $json.body.match(/\\.P\\s+(\\d+)/)?.[1] || '15' }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "99336995-2326-4575-9970-26afcf957132",
|
||||
"id": "97d5b0ad-d078-411f-8f34-c9a81d18d921",
|
||||
"name": "Parse Signal",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.2,
|
||||
"position": [
|
||||
-660,
|
||||
660
|
||||
-780,
|
||||
680
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"string": [
|
||||
{
|
||||
"value1": "={{ $json.timeframe }}",
|
||||
"operation": "equals",
|
||||
"value2": "15"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "2e0bf241-9fb6-40bd-89f6-2dceafe34ef9",
|
||||
"name": "15min Chart Only?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
-560,
|
||||
540
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -74,12 +95,12 @@
|
||||
"jsonBody": "={\n \"symbol\": \"{{ $json.symbol }}\",\n \"direction\": \"{{ $json.direction }}\"\n}",
|
||||
"options": {}
|
||||
},
|
||||
"id": "d42e7897-eadd-4202-8565-ac60759b46e1",
|
||||
"id": "c1165de4-2095-4f5f-b9b1-18e76fd8c47b",
|
||||
"name": "Check Risk",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4,
|
||||
"position": [
|
||||
-340,
|
||||
-280,
|
||||
660
|
||||
],
|
||||
"credentials": {
|
||||
@@ -100,12 +121,12 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "a60bfecb-d2f4-4165-a609-e6ed437aa2aa",
|
||||
"id": "b9fa2b47-2acd-4be0-9d50-3f0348e04ec6",
|
||||
"name": "Risk Passed?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
-140,
|
||||
-80,
|
||||
660
|
||||
]
|
||||
},
|
||||
@@ -135,7 +156,7 @@
|
||||
"timeout": 120000
|
||||
}
|
||||
},
|
||||
"id": "95c46846-4b6a-4f9e-ad93-be223b73a618",
|
||||
"id": "c2ec5f8c-42d1-414f-bdd6-0a440bc8fea9",
|
||||
"name": "Execute Trade",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4,
|
||||
@@ -161,7 +182,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "18342642-e76f-484f-b532-d29846536a9c",
|
||||
"id": "16dbf434-a07c-4666-82f2-cdc8814fe216",
|
||||
"name": "Trade Success?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 1,
|
||||
@@ -182,7 +203,7 @@
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "9da40e3d-b855-4c65-a032-c6fcf88245d4",
|
||||
"id": "79ab6122-cbd3-4aac-97d7-6b54f64e29b5",
|
||||
"name": "Format Success",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.2,
|
||||
@@ -203,7 +224,7 @@
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "500751c7-21bb-4351-8a6a-d43a1bfb9eaa",
|
||||
"id": "41a0a8be-5004-4e6d-bdc5-9c7edf04eb51",
|
||||
"name": "Format Error",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.2,
|
||||
@@ -224,7 +245,7 @@
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "dec6cbc4-7550-40d3-9195-c4cc4f787b9b",
|
||||
"id": "da462967-0548-4d57-a6de-cb783c96ac07",
|
||||
"name": "Format Risk",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.2,
|
||||
@@ -241,7 +262,7 @@
|
||||
"appendAttribution": false
|
||||
}
|
||||
},
|
||||
"id": "6267b604-d39b-4cb7-98a5-2342cdced33b",
|
||||
"id": "254280fd-f547-4302-97a5-30b44d851e12",
|
||||
"name": "Telegram Success",
|
||||
"type": "n8n-nodes-base.telegram",
|
||||
"typeVersion": 1.1,
|
||||
@@ -259,12 +280,12 @@
|
||||
{
|
||||
"parameters": {
|
||||
"chatId": "579304651",
|
||||
"text": "{{ `🟢 TRADE OPENED\\n\\n📊 Symbol: ${$('Parse Signal').item.json.symbol}\\n${$('Parse Signal').item.json.direction === 'long' ? '📈' : '📉'} Direction: ${$('Parse Signal').item.json.direction.toUpperCase()}\\n\\n💰 Entry: $${$json.entryPrice.toFixed(4)}\\n🎯 TP1: $${$json.takeProfit1.toFixed(4)} (${$json.tp1Percent}%)\\n🎯 TP2: $${$json.takeProfit2.toFixed(4)} (${$json.tp2Percent}%)\\n🛑 SL: $${$json.stopLoss.toFixed(4)} (${$json.stopLossPercent}%)\\n\\n⏰ ${$now.toFormat('HH:mm:ss')}\\n✅ Position monitored` }}",
|
||||
"text": "={{ $json.message }}",
|
||||
"additionalFields": {
|
||||
"appendAttribution": false
|
||||
}
|
||||
},
|
||||
"id": "88224fac-ef7a-41ec-b68a-e4bc1a5e3f31",
|
||||
"id": "4ea066c9-4971-408f-b6e2-7d704c13ef55",
|
||||
"name": "Telegram Error",
|
||||
"type": "n8n-nodes-base.telegram",
|
||||
"typeVersion": 1.1,
|
||||
@@ -287,7 +308,7 @@
|
||||
"appendAttribution": false
|
||||
}
|
||||
},
|
||||
"id": "4eccaca4-a5e7-407f-aab9-663a98a8323b",
|
||||
"id": "ee6be7be-1735-4fa3-bd33-6b3fde9414d3",
|
||||
"name": "Telegram Risk",
|
||||
"type": "n8n-nodes-base.telegram",
|
||||
"typeVersion": 1.1,
|
||||
@@ -304,46 +325,23 @@
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"chatId": "579304651",
|
||||
"text": "={{ $json.signal.startsWith(\"Buy\") ? \"🟢 \" + $json.signal : \"🔴 \" + $json.signal }}\n",
|
||||
"additionalFields": {
|
||||
"appendAttribution": false
|
||||
}
|
||||
},
|
||||
"id": "5a8eda4d-8945-4144-8672-022c9ee68bf6",
|
||||
"name": "Telegram",
|
||||
"type": "n8n-nodes-base.telegram",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
-340,
|
||||
840
|
||||
],
|
||||
"credentials": {
|
||||
"telegramApi": {
|
||||
"id": "Csk5cg4HtaSqP5jJ",
|
||||
"name": "Telegram account"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"fields": {
|
||||
"values": [
|
||||
"conditions": {
|
||||
"string": [
|
||||
{
|
||||
"name": "signal",
|
||||
"stringValue": "={{ $json.body.split('|')[0].trim() }}"
|
||||
"value1": "={{ $json.timeframe }}",
|
||||
"operation": "equals",
|
||||
"value2": "5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
}
|
||||
},
|
||||
"id": "cce16424-fbb1-4191-b719-79ccfd59ec12",
|
||||
"name": "Edit Fields",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.2,
|
||||
"id": "8c680565-120d-47dc-83b2-58dcd397168b",
|
||||
"name": "5min Chart Only?1",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
-660,
|
||||
840
|
||||
-560,
|
||||
800
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -364,12 +362,12 @@
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Check Risk",
|
||||
"node": "15min Chart Only?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"node": "Telegram",
|
||||
"node": "5min Chart Only?1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
@@ -467,18 +465,35 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"Edit Fields": {
|
||||
"15min Chart Only?": {
|
||||
"main": [
|
||||
[]
|
||||
[
|
||||
{
|
||||
"node": "Check Risk",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"5min Chart Only?1": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Check Risk",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"active": true,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"versionId": "2cc10693-953a-4b97-8c86-750b3063096b",
|
||||
"id": "xTCaxlyI02bQLxun",
|
||||
"versionId": "1376fb3b-08fb-4d96-a038-371249d36eda",
|
||||
"id": "gUDqTiHyHSfRUXv6",
|
||||
"meta": {
|
||||
"instanceId": "e766d4f0b5def8ee8cb8561cd9d2b9ba7733e1907990b6987bca40175f82c379"
|
||||
},
|
||||
204
workflows/trading/moneyline_v5_final.pinescript
Normal file
204
workflows/trading/moneyline_v5_final.pinescript
Normal file
@@ -0,0 +1,204 @@
|
||||
//@version=5
|
||||
indicator("Bullmania Money Line v5 Optimzed Final", overlay=true)
|
||||
|
||||
// Calculation source (Chart vs Heikin Ashi)
|
||||
srcMode = input.string("Chart", "Calculation source", options=["Chart","Heikin Ashi"], tooltip="Use regular chart candles or Heikin Ashi for the line calculation.")
|
||||
|
||||
// Parameter Mode
|
||||
paramMode = input.string("Profiles by timeframe", "Parameter Mode", options=["Single", "Profiles by timeframe"], tooltip="Choose whether to use one global set of parameters or timeframe-specific profiles.")
|
||||
|
||||
// Single (global) parameters
|
||||
atrPeriodSingle = input.int(10, "ATR Period (Single mode)", minval=1, group="Single Mode")
|
||||
multiplierSingle = input.float(3.0, "Multiplier (Single mode)", minval=0.1, step=0.1, group="Single Mode")
|
||||
|
||||
// Profile override when using profiles
|
||||
profileOverride = input.string("Auto", "Profile Override", options=["Auto", "Minutes", "Hours", "Daily", "Weekly/Monthly"], tooltip="When in 'Profiles by timeframe' mode, choose a fixed profile or let it auto-detect from the chart timeframe.", group="Profiles")
|
||||
|
||||
// Timeframe profile parameters
|
||||
// Minutes (<= 59m)
|
||||
atr_m = input.int(12, "ATR Period (Minutes)", minval=1, group="Profiles — Minutes")
|
||||
mult_m = input.float(3.3, "Multiplier (Minutes)", minval=0.1, step=0.1, group="Profiles — Minutes")
|
||||
|
||||
// Hours (>=1h and <1d)
|
||||
atr_h = input.int(10, "ATR Period (Hours)", minval=1, group="Profiles — Hours")
|
||||
mult_h = input.float(3.0, "Multiplier (Hours)", minval=0.1, step=0.1, group="Profiles — Hours")
|
||||
|
||||
// Daily (>=1d and <1w)
|
||||
atr_d = input.int(10, "ATR Period (Daily)", minval=1, group="Profiles — Daily")
|
||||
mult_d = input.float(2.8, "Multiplier (Daily)", minval=0.1, step=0.1, group="Profiles — Daily")
|
||||
|
||||
// Weekly/Monthly (>=1w)
|
||||
atr_w = input.int(7, "ATR Period (Weekly/Monthly)", minval=1, group="Profiles — Weekly/Monthly")
|
||||
mult_w = input.float(2.5, "Multiplier (Weekly/Monthly)", minval=0.1, step=0.1, group="Profiles — Weekly/Monthly")
|
||||
|
||||
// Optional MACD confirmation
|
||||
useMacd = input.bool(false, "Use MACD confirmation", inline="macd")
|
||||
macdSrc = input.source(close, "MACD Source", inline="macd")
|
||||
macdFastLen = input.int(12, "Fast", minval=1, inline="macdLens")
|
||||
macdSlowLen = input.int(26, "Slow", minval=1, inline="macdLens")
|
||||
macdSigLen = input.int(9, "Signal", minval=1, inline="macdLens")
|
||||
|
||||
// Entry filters (optional)
|
||||
groupFilters = "Entry filters"
|
||||
useEntryBuffer = input.bool(false, "Require entry buffer (ATR)", group=groupFilters, tooltip="If enabled, the close must be beyond the Money Line by the buffer amount to avoid wick flips.")
|
||||
entryBufferATR = input.float(0.15, "Buffer size (in ATR)", minval=0.0, step=0.05, group=groupFilters, tooltip="0.10–0.20 works well on 1h.")
|
||||
confirmBars = input.int(0, "Bars to confirm after flip", minval=0, maxval=2, group=groupFilters, tooltip="0 = signal on flip bar. 1 = wait one bar.")
|
||||
useAdx = input.bool(false, "Use ADX trend-strength filter", group=groupFilters, tooltip="If enabled, require ADX to be above a threshold to reduce chop.")
|
||||
adxLen = input.int(14, "ADX Length", minval=1, group=groupFilters)
|
||||
adxMin = input.int(20, "ADX minimum", minval=0, maxval=100, group=groupFilters)
|
||||
|
||||
// Determine effective parameters based on selected mode/profile
|
||||
var string activeProfile = ""
|
||||
resSec = timeframe.in_seconds(timeframe.period)
|
||||
isMinutes = resSec < 3600
|
||||
isHours = resSec >= 3600 and resSec < 86400
|
||||
isDaily = resSec >= 86400 and resSec < 604800
|
||||
isWeeklyOrMore = resSec >= 604800
|
||||
|
||||
// Resolve profile bucket
|
||||
string profileBucket = "Single"
|
||||
if paramMode == "Single"
|
||||
profileBucket := "Single"
|
||||
else
|
||||
if profileOverride == "Minutes"
|
||||
profileBucket := "Minutes"
|
||||
else if profileOverride == "Hours"
|
||||
profileBucket := "Hours"
|
||||
else if profileOverride == "Daily"
|
||||
profileBucket := "Daily"
|
||||
else if profileOverride == "Weekly/Monthly"
|
||||
profileBucket := "Weekly/Monthly"
|
||||
else
|
||||
profileBucket := isMinutes ? "Minutes" : isHours ? "Hours" : isDaily ? "Daily" : "Weekly/Monthly"
|
||||
|
||||
atrPeriod = profileBucket == "Single" ? atrPeriodSingle : profileBucket == "Minutes" ? atr_m : profileBucket == "Hours" ? atr_h : profileBucket == "Daily" ? atr_d : atr_w
|
||||
multiplier = profileBucket == "Single" ? multiplierSingle : profileBucket == "Minutes" ? mult_m : profileBucket == "Hours" ? mult_h : profileBucket == "Daily" ? mult_d : mult_w
|
||||
activeProfile := profileBucket
|
||||
|
||||
// Core Money Line logic (with selectable source)
|
||||
// Build selected source OHLC
|
||||
// Optimized: Calculate Heikin Ashi directly instead of using request.security()
|
||||
haC = srcMode == "Heikin Ashi" ? (open + high + low + close) / 4 : close
|
||||
haO = srcMode == "Heikin Ashi" ? (nz(haC[1]) + nz(open[1])) / 2 : open
|
||||
haH = srcMode == "Heikin Ashi" ? math.max(high, math.max(haO, haC)) : high
|
||||
haL = srcMode == "Heikin Ashi" ? math.min(low, math.min(haO, haC)) : low
|
||||
calcH = haH
|
||||
calcL = haL
|
||||
calcC = haC
|
||||
|
||||
// ATR on selected source
|
||||
tr = math.max(calcH - calcL, math.max(math.abs(calcH - calcC[1]), math.abs(calcL - calcC[1])))
|
||||
atr = ta.rma(tr, atrPeriod)
|
||||
src = (calcH + calcL) / 2
|
||||
|
||||
up = src - (multiplier * atr)
|
||||
dn = src + (multiplier * atr)
|
||||
|
||||
var float up1 = na
|
||||
var float dn1 = na
|
||||
|
||||
up1 := nz(up1[1], up)
|
||||
dn1 := nz(dn1[1], dn)
|
||||
|
||||
up1 := calcC[1] > up1 ? math.max(up, up1) : up
|
||||
dn1 := calcC[1] < dn1 ? math.min(dn, dn1) : dn
|
||||
|
||||
var int trend = 1
|
||||
var float tsl = na
|
||||
|
||||
tsl := nz(tsl[1], up1)
|
||||
|
||||
if trend == 1
|
||||
tsl := math.max(up1, tsl)
|
||||
trend := calcC < tsl ? -1 : 1
|
||||
else
|
||||
tsl := math.min(dn1, tsl)
|
||||
trend := calcC > tsl ? 1 : -1
|
||||
|
||||
supertrend = tsl
|
||||
|
||||
// Plot the Money Line
|
||||
upTrend = trend == 1 ? supertrend : na
|
||||
downTrend = trend == -1 ? supertrend : na
|
||||
|
||||
plot(upTrend, "Up Trend", color=color.new(color.green, 0), style=plot.style_linebr, linewidth=2)
|
||||
plot(downTrend, "Down Trend", color=color.new(color.red, 0), style=plot.style_linebr, linewidth=2)
|
||||
|
||||
// Show active profile on chart as a label (optimized - only on confirmed bar)
|
||||
showProfileLabel = input.bool(true, "Show active profile label", group="Profiles")
|
||||
var label profLbl = na
|
||||
if barstate.islast and barstate.isconfirmed and showProfileLabel
|
||||
label.delete(profLbl)
|
||||
profLbl := label.new(bar_index, close, text="Profile: " + activeProfile + " | ATR=" + str.tostring(atrPeriod) + " Mult=" + str.tostring(multiplier), yloc=yloc.price, style=label.style_label_upper_left, textcolor=color.white, color=color.new(color.blue, 20))
|
||||
|
||||
// MACD confirmation logic
|
||||
[macdLine, macdSignal, macdHist] = ta.macd(macdSrc, macdFastLen, macdSlowLen, macdSigLen)
|
||||
longOk = not useMacd or (macdLine > macdSignal)
|
||||
shortOk = not useMacd or (macdLine < macdSignal)
|
||||
|
||||
// Plot buy/sell signals (gated by optional MACD)
|
||||
buyFlip = trend == 1 and trend[1] == -1
|
||||
sellFlip = trend == -1 and trend[1] == 1
|
||||
|
||||
// ADX computation (always calculate for context, but only filter if enabled)
|
||||
upMove = calcH - calcH[1]
|
||||
downMove = calcL[1] - calcL
|
||||
plusDM = (upMove > downMove and upMove > 0) ? upMove : 0.0
|
||||
minusDM = (downMove > upMove and downMove > 0) ? downMove : 0.0
|
||||
trADX = math.max(calcH - calcL, math.max(math.abs(calcH - calcC[1]), math.abs(calcL - calcC[1])))
|
||||
atrADX = ta.rma(trADX, adxLen)
|
||||
plusDMSmooth = ta.rma(plusDM, adxLen)
|
||||
minusDMSmooth = ta.rma(minusDM, adxLen)
|
||||
plusDI = atrADX == 0.0 ? 0.0 : 100.0 * plusDMSmooth / atrADX
|
||||
minusDI = atrADX == 0.0 ? 0.0 : 100.0 * minusDMSmooth / atrADX
|
||||
dx = (plusDI + minusDI == 0.0) ? 0.0 : 100.0 * math.abs(plusDI - minusDI) / (plusDI + minusDI)
|
||||
adxVal = ta.rma(dx, adxLen)
|
||||
adxOk = not useAdx or (adxVal > adxMin)
|
||||
|
||||
// Entry buffer gates relative to current Money Line
|
||||
longBufferOk = not useEntryBuffer or (calcC > supertrend + entryBufferATR * atr)
|
||||
shortBufferOk = not useEntryBuffer or (calcC < supertrend - entryBufferATR * atr)
|
||||
|
||||
// Confirmation bars after flip
|
||||
buyReady = ta.barssince(buyFlip) == confirmBars
|
||||
sellReady = ta.barssince(sellFlip) == confirmBars
|
||||
|
||||
// Final gated signals
|
||||
finalLongSignal = buyReady and longOk and adxOk and longBufferOk
|
||||
finalShortSignal = sellReady and shortOk and adxOk and shortBufferOk
|
||||
|
||||
plotshape(finalLongSignal, title="Buy Signal", location=location.belowbar, color=color.green, style=shape.circle, size=size.small)
|
||||
plotshape(finalShortSignal, title="Sell Signal", location=location.abovebar, color=color.red, style=shape.circle, size=size.small)
|
||||
|
||||
// === CONTEXT METRICS FOR SIGNAL QUALITY ===
|
||||
// Calculate ATR as percentage of price
|
||||
atrPercent = (atr / calcC) * 100
|
||||
|
||||
// Calculate RSI
|
||||
rsi14 = ta.rsi(calcC, 14)
|
||||
|
||||
// Volume ratio (current volume vs 20-bar MA)
|
||||
volMA20 = ta.sma(volume, 20)
|
||||
volumeRatio = volume / volMA20
|
||||
|
||||
// Price position in recent 20-bar range (0-100%)
|
||||
highest20 = ta.highest(calcH, 20)
|
||||
lowest20 = ta.lowest(calcL, 20)
|
||||
priceRange = highest20 - lowest20
|
||||
pricePosition = priceRange == 0 ? 50.0 : ((calcC - lowest20) / priceRange) * 100
|
||||
|
||||
// Build enhanced alert messages with context
|
||||
longAlertMsg = "SOL buy .P " + str.tostring(timeframe.period) + " | ATR:" + str.tostring(atrPercent, "#.##") + " | ADX:" + str.tostring(adxVal, "#.#") + " | RSI:" + str.tostring(rsi14, "#.#") + " | VOL:" + str.tostring(volumeRatio, "#.##") + " | POS:" + str.tostring(pricePosition, "#.#")
|
||||
|
||||
shortAlertMsg = "SOL sell .P " + str.tostring(timeframe.period) + " | ATR:" + str.tostring(atrPercent, "#.##") + " | ADX:" + str.tostring(adxVal, "#.#") + " | RSI:" + str.tostring(rsi14, "#.#") + " | VOL:" + str.tostring(volumeRatio, "#.##") + " | POS:" + str.tostring(pricePosition, "#.#")
|
||||
|
||||
// Fire alerts with dynamic messages (use alert() not alertcondition() for dynamic content)
|
||||
if finalLongSignal
|
||||
alert(longAlertMsg, alert.freq_once_per_bar_close)
|
||||
|
||||
if finalShortSignal
|
||||
alert(shortAlertMsg, alert.freq_once_per_bar_close)
|
||||
|
||||
// Fill area between price and Money Line
|
||||
fill(plot(close, display=display.none), plot(upTrend, display=display.none), color=color.new(color.green, 90))
|
||||
fill(plot(close, display=display.none), plot(downTrend, display=display.none), color=color.new(color.red, 90))
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
{
|
||||
"name": "timeframe",
|
||||
"stringValue": "5"
|
||||
"stringValue": "={{ $json.body.match(/\\.P\\s+(\\d+)/)?.[1] || '15' }}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -44,6 +44,24 @@
|
||||
"typeVersion": 3.2,
|
||||
"position": [440, 400]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"string": [
|
||||
{
|
||||
"value1": "={{ $json.timeframe }}",
|
||||
"operation": "equals",
|
||||
"value2": "15"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"id": "timeframe-filter",
|
||||
"name": "15min Chart Only?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 1,
|
||||
"position": [540, 400]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
@@ -72,7 +90,7 @@
|
||||
"name": "Check Risk",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4,
|
||||
"position": [640, 400]
|
||||
"position": [740, 400]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
@@ -89,7 +107,7 @@
|
||||
"name": "Risk Passed?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 1,
|
||||
"position": [840, 400]
|
||||
"position": [940, 400]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
Reference in New Issue
Block a user