28 Commits

Author SHA1 Message Date
mindesbunister
781b88f803 Enhance TradingView indicator with context metrics for signal quality
Added 5 context metrics to alert messages:
- ATR% (volatility as % of price)
- ADX (trend strength)
- RSI (momentum)
- VOL (volume ratio vs 20-bar MA)
- POS (price position in 20-bar range 0-100%)

Changes to Pine Script:
- Always calculate ADX (needed for context even if filter disabled)
- Extract ta.rma() calls outside ternary operators (Pine Script requirement)
- Use alert() instead of alertcondition() for dynamic message support
- Changed to single-line string concatenation for compatibility

Alert message format:
OLD: 'Buy SOL 15 | Profile=Hours ATR=10 Mult=3.0'
NEW: 'SOL buy .P 15 | ATR:1.85 | ADX:28.3 | RSI:62.5 | VOL:1.45 | POS:75.3'

Next: Update n8n to parse these metrics, implement signal quality scoring in bot
2025-10-30 15:53:48 +01:00
mindesbunister
7c4adff4e4 Implement risk checks: cooldown, hourly limit, and daily drawdown
Implemented 3 critical risk checks in /api/trading/check-risk:

1. Daily Drawdown Check
   - Blocks trades if today's P&L < maxDailyDrawdown
   - Prevents catastrophic daily losses
   - Currently: -0 limit (configurable via MAX_DAILY_DRAWDOWN)

2. Hourly Trade Limit
   - Blocks trades if tradesInLastHour >= maxTradesPerHour
   - Prevents overtrading / algorithm malfunction
   - Currently: 20 trades/hour (configurable via MAX_TRADES_PER_HOUR)

3. Cooldown Period
   - Blocks trades if timeSinceLastTrade < minTimeBetweenTrades
   - Enforces breathing room between trades
   - Uses minutes (not seconds) thanks to previous commit
   - Currently: 0 min = disabled (configurable via MIN_TIME_BETWEEN_TRADES)

Added database helper functions:
- getLastTradeTime() - Returns timestamp of most recent trade
- getTradesInLastHour() - Counts trades in last 60 minutes
- getTodayPnL() - Sums realized P&L since midnight

All checks include detailed logging with values and thresholds.
Risk check called by n8n workflow before every trade execution.
2025-10-30 10:50:08 +01:00
mindesbunister
b7b0fb9bb2 Change cooldown unit from seconds to minutes
- Updated minTimeBetweenTrades config to use minutes instead of seconds
- Changed default from 600 seconds to 10 minutes
- Updated Settings UI label from 'seconds' to 'minutes' and adjusted range (0-60 min)
- Updated .env comments to reflect new unit
- No functional change since cooldown enforcement not yet implemented (TODO in check-risk route)
2025-10-30 10:35:47 +01:00
mindesbunister
25d31ff75a Fix: Save MAE/MFE values when trades exit
Bug: MAE/MFE was tracked in memory during trades but not saved to database on exit
Cause: updateTradeExit() wasn't receiving or saving MAE/MFE parameters

Changes:
- Added MAE/MFE fields to UpdateTradeExitParams interface
- Modified updateTradeExit() to save maxFavorableExcursion, maxAdverseExcursion, maxFavorablePrice, maxAdversePrice
- Updated both updateTradeExit() calls in Position Manager to pass MAE/MFE values
- Enhanced exit logging to show final MAE/MFE percentages

Impact: Future trades will now properly save MAE/MFE data for analytics
Note: Past 2 trades (from before this fix) don't have MAE/MFE saved
2025-10-30 07:37:10 +01:00
mindesbunister
6e87fc8749 Phase 4: TP/SL Optimization Visual Dashboard
- Created /analytics/optimization page with comprehensive UI
- Displays MAE/MFE analysis with percentiles
- Shows current TP/SL performance with hit rate bars
- Visualizes optimal recommendations vs current levels
- Projects impact of optimization (win rate, profit factor, P&L improvement)
- Provides reasoning for each recommended level
- Added navigation link from main analytics page

Dashboard features:
- Overview stats: total trades, win rate, profit factor, money left on table
- MAE analysis: avg, median, 25th/75th percentile, worst
- MFE analysis: avg, median, 25th/75th percentile, best
- Current config: TP1/TP2/SL hit rates with progress bars
- Recommendations: optimal levels with color-coded cards
- Reasoning cards: explanation for each recommendation
- Projected impact: win rate change, profit factor change, profit improvement
- Direct link to Settings page to apply recommendations

Access at: http://localhost:3001/analytics/optimization

Phase 1-4 Complete! System now tracks MAE/MFE, captures market context,
analyzes performance, and provides data-driven TP/SL recommendations.
2025-10-29 21:19:52 +01:00
mindesbunister
da72b5de04 Phase 3: TP/SL Optimization Analytics API
- Created /api/analytics/tp-sl-optimization endpoint
- Analyzes historical trades using MAE/MFE data
- Calculates optimal TP1/TP2/SL levels based on percentiles
- Provides win rate, profit factor, and hit rate analysis
- Shows money left on table (MFE - realized P&L)
- Projects impact of optimal levels on future performance

Analytics calculated:
- MAE analysis: avg, median, percentiles, worst
- MFE analysis: avg, median, percentiles, best
- Current level performance: TP1/TP2/SL hit rates
- Optimal recommendations: TP1=50% of avg MFE, TP2=80%, SL=70% of avg MAE
- Projected improvements: win rate change, profit factor, total P&L

Requires 10+ closed trades with MAE/MFE data to generate recommendations
Test script: scripts/test-analytics.sh

Next: Phase 4 (visual dashboard) or wait for trades with MAE/MFE data
2025-10-29 21:11:23 +01:00
mindesbunister
e068c5f2e6 Phase 2: Market context capture at entry
- Added getFundingRate() method to DriftService
- Capture expectedEntryPrice from oracle before order execution
- Capture fundingRateAtEntry from Drift Protocol
- Save market context fields to database (expectedEntryPrice, fundingRateAtEntry)
- Calculate entry slippage percentage in createTrade()
- Fixed template literal syntax errors in execute endpoint

Database fields populated:
- expectedEntryPrice: Oracle price before order
- entrySlippagePct: Calculated from entrySlippage
- fundingRateAtEntry: Current funding rate from Drift

Next: Phase 3 (analytics API) or test market context on next trade
2025-10-29 20:51:46 +01:00
mindesbunister
65e6a8efed Phase 1: Add MAE/MFE tracking and analytics schema
- Added 20+ analytics fields to Trade model (MAE/MFE, fill tracking, timing, market context, slippage)
- Implemented real-time MAE/MFE tracking in Position Manager (updates every 5s)
- Enhanced database schema with comprehensive trade analytics
- Updated all API endpoints to initialize MAE/MFE fields
- Modified updateTradeState() to persist MAE/MFE in configSnapshot

Database changes:
- maxFavorableExcursion/maxAdverseExcursion track best/worst profit %
- maxFavorablePrice/maxAdversePrice track exact price levels
- Fill tracking: tp1Filled, tp2Filled, softSlFilled, hardSlFilled
- Timing metrics: timeToTp1, timeToTp2, timeToSl
- Market context: atrAtEntry, adxAtEntry, volumeAtEntry, fundingRateAtEntry, basisAtEntry
- Slippage tracking: expectedEntryPrice, entrySlippagePct, expectedExitPrice, exitSlippagePct

Position Manager changes:
- Track MAE/MFE on every price check (2s interval)
- Throttled database updates (5s interval) via updateTradeMetrics()
- Persist MAE/MFE in trade state snapshots for recovery

Next: Phase 2 (market context capture) or Phase 3 (analytics API)
2025-10-29 20:34:03 +01:00
mindesbunister
d4d2883af6 Fix: Prevent Position Manager from closing runner after on-chain TP2
- Detect on-chain TP2 fills in size mismatch logic and set tp2Hit flag
- Position size thresholds: <30% = TP1, <10% = TP2 (prevents runner from being closed)
- Ensures runner (5-20%) trails properly instead of being market-closed immediately
2025-10-29 20:04:33 +01:00
mindesbunister
797e80b56a CRITICAL FIX: TP/SL orders using wrong size calculation
**ROOT CAUSE:** placeExitOrders() calculated position size using TP/SL prices instead of entry price

**Problem:**
- TP1 order size: 85 / TP1_price (00.746) = 2.914 SOL
- Actual position: 80 / entry_price (99.946) = 3.901 SOL
- TP1 should close: 3.901 * 75% = 2.926 SOL
- But it only closed: 2.914 SOL = 74.7%  WRONG!

**Result:** TP1 closed ~25% instead of 75%, no runner left

**Fix:**
- Changed usdToBase() to use entryPrice for ALL size calculations
- Added entryPrice param to PlaceExitOrdersOptions interface
- Updated all API routes to pass entryPrice

**Testing:** Next trade will have correctly sized TP/SL orders
2025-10-29 17:34:10 +01:00
mindesbunister
f7cf9ec63b Fix database race condition and Drift initialization errors
- Remove saveTradeState() call from addTrade() to avoid P2025 error
- Add initialization check in checkTradeConditions() to skip when Drift not ready
- Silence 'not initialized' errors during startup (expected behavior)
- Trade state is now saved only by API endpoint after DB record created
2025-10-29 16:00:06 +01:00
mindesbunister
344a79a753 Fix runner activation and order cancellation
- Change takeProfit2SizePercent from 100% to 80% to leave 5% runner
- Fix cancelAllOrders() to detect trigger orders using orderId > 0
- Trigger orders (TRIGGER_MARKET, TRIGGER_LIMIT) now properly canceled
- Trailing stop will now activate on 5% runner position
2025-10-29 15:38:47 +01:00
mindesbunister
fe4d9bc954 Fix: Calculate P&L correctly for external closures
- Save currentSize before it becomes 0 in external closure detection
- Use sizeBeforeClosure for P&L calculation instead of trade.currentSize
- Prevents /bin/bash.00 P&L for TP2 exits when position closes externally
- Ensures win/loss analytics counts TP trades correctly
2025-10-28 20:10:38 +01:00
mindesbunister
27f78748cf Fix: Initialize Drift service before cancelling orders
- cancelAllOrders() now calls initializeDriftService() if service not initialized
- Prevents 'Drift service not initialized' error when Position Manager tries to cancel orphaned orders
- Ensures order cleanup works correctly after external position closures
2025-10-28 20:00:17 +01:00
mindesbunister
715fa8bd11 Update README with comprehensive architecture, dual-layer redundancy, and current features
- Add dual-layer redundancy explanation (on-chain + Position Manager)
- Document dual stop-loss system (soft TRIGGER_LIMIT + hard TRIGGER_MARKET)
- Add complete trade flow documentation (signal → execution → monitoring → exit)
- Update supported timeframes (5min and 15min)
- Document database integration (PostgreSQL + Prisma)
- Add configuration system details (three-layer merge)
- Document recent bug fixes (TP2 calculation, race condition, order cancellation)
- Add comprehensive file structure
- Update API endpoints with all current routes
- Add real-world trade examples with P&L calculations
- Document singleton patterns and critical coding practices
- Add troubleshooting section with common issues
- Update testing commands and safety guidelines
2025-10-28 12:08:18 +01:00
mindesbunister
e8a9b68fa7 Fix: Critical bugs - TP2 runner calculation + race condition + order cleanup
**Issue 1: TP2 Runner Position Bug**  FIXED
- TP2 was calculated as 80% of ORIGINAL position instead of REMAINING
- With TP1=75%, TP2=80%: Was closing 75%+80%=155% (capped at 100%)
- Now correctly: TP1 closes 75%, TP2 closes 80% of remaining 25% = 20%
- Result: 5% runner now remains for trailing stop as intended!

**Issue 2: Race Condition - Orphaned SL Orders**  FIXED
- Orders were placed AFTER Position Manager started monitoring
- If TP hit fast, PM detected 'external closure' before orders finished
- Orders completed after position gone → orphaned SL orders on Drift
- Now: Exit orders placed BEFORE starting monitoring
- PM can now properly cancel remaining orders when position closes

**Issue 3: 5min vs 15min Timeframe** ⚠️ NEEDS VERIFICATION
- n8n workflow correctly filters for timeframe === '15'
- Extracts timeframe with regex: /\.P\s+(\d+)/
- User needs to verify TradingView alert includes '.P 15' in message
- Format should be: 'SOL buy .P 15' not just 'SOL buy'

**Technical Changes:**
- lib/drift/orders.ts: Fixed TP2 calculation to use remaining size
- Added logging: Shows TP1, TP2, remaining, and runner amounts
- app/api/trading/execute/route.ts: Reordered to place orders before monitoring
- Prevents race condition where orders complete after position closed

**Testing:**
- Next trade will show proper runner position (5% remains)
- No more orphaned SL orders after wins
- Logs will show: 'Runner (if any): $X.XX'

**Documentation:**
- Created CRITICAL_ISSUES_FOUND.md explaining all 3 issues
- Created FIXES_APPLIED.md with testing instructions
2025-10-28 10:12:04 +01:00
mindesbunister
19f5b7ab14 Fix: Critical Position Manager monitoring issues
**Root Cause:** Position Manager didn't detect when on-chain TP/SL orders closed positions externally, causing endless error loops and stale position data.

**Issues Fixed:**
1. Position Manager now checks if on-chain position still exists before attempting to close
2. Detects external closures (by on-chain orders) and updates database accordingly
3. Determines likely exit reason based on price vs TP/SL levels
4. Automatically cancels leftover orders when position detected as closed
5. Analytics now properly shows stopped-out trades

**Technical Changes:**
- Added position existence check at start of checkTradeConditions()
- Calls DriftService.getPosition() to verify on-chain state
- Updates database with exitPrice, exitReason, realizedPnL when external closure detected
- Removes trade from monitoring after external closure
- Handles size mismatches for partial closes (TP1 hit externally)

**Database Fix:**
- Manually closed orphaned trade (stopped out 9 hours ago but still marked 'open')
- Calculated and set realizedPnL = -$12.00 for stopped-out SHORT position
- Analytics now shows 3 total trades instead of missing the SL exit

**Testing:**
- Bot starts cleanly with no error loops
- Position monitoring active with 0 trades (as expected)
- Analytics correctly shows stopped-out trade in statistics
2025-10-28 07:51:40 +01:00
mindesbunister
a72ddd8f0e Fix: Position Manager initialization race condition in API endpoints
- Changed /api/trading/positions to use getInitializedPositionManager()
- Changed /api/trading/test to use getInitializedPositionManager()
- Changed /api/trading/test-db to use getInitializedPositionManager()
- These endpoints were accessing Position Manager before DB restore completed
- Now properly wait for async initialization before accessing trade data
- Fixes /status Telegram command showing empty despite active positions
2025-10-27 23:38:24 +01:00
mindesbunister
9bf83260c4 Add /close command and auto-flip logic with order cleanup
- Added /close Telegram command for full position closure
- Updated /reduce to accept 10-100% (was 10-90%)
- Implemented auto-flip logic: automatically closes opposite position when signal reverses
- Fixed risk check to allow opposite direction trades (signal flips)
- Enhanced Position Manager to cancel orders when removing trades
- Added startup initialization for Position Manager (restores trades on restart)
- Fixed analytics to show stopped-out trades (manual DB update for orphaned trade)
- Updated reduce endpoint to route 100% closes through closePosition for proper cleanup
- All position closures now guarantee TP/SL order cancellation on Drift
2025-10-27 23:27:48 +01:00
mindesbunister
a07bf9f4b2 Add position reduction feature via Telegram
- New endpoint: /api/trading/reduce-position to take partial profits
- Closes specified percentage at market price
- Recalculates and places new TP/SL orders for remaining size
- Entry price stays the same, only size is reduced
- Telegram command: /reduce [percent] (default 50%, range 10-90%)
- Shows realized P&L from the closed portion
- Example: /reduce 25 closes 25% and updates orders for remaining 75%
2025-10-27 20:34:47 +01:00
mindesbunister
1acb5e7210 Add position scaling feature via Telegram
- New endpoint: /api/trading/scale-position to add to existing positions
- Calculates new average entry price after adding more size
- Cancels old TP/SL orders and places new ones at updated levels
- Telegram command: /scale [percent] (default 50%)
- Example: /scale 100 doubles your position
- Automatically adjusts Position Manager tracking with new values
- Cleaned up stale duplicate trade from database
2025-10-27 20:24:06 +01:00
mindesbunister
6a04d3469f Add remove-position endpoint and clean up stale position
- New endpoint: /api/trading/remove-position for manually removing stale positions
- Removed duplicate position from tracking (second SOL-PERP position)
- System now correctly shows 1 active position matching Drift
- Validation and analytics will now show accurate position count
2025-10-27 19:58:57 +01:00
mindesbunister
9808d52d3f Fix Telegram bot environment configuration
- Updated .env with correct TELEGRAM_BOT_TOKEN and N8N_WEBHOOK_URL
- Added env_file directive to docker-compose.telegram-bot.yml
- Telegram bot now starts successfully with /validate command working
2025-10-27 19:31:06 +01:00
mindesbunister
dde25ad2c1 Add position validation endpoint and Telegram /validate command
- New API endpoint: /api/trading/validate-positions
- Validates TP1, TP2, SL, leverage, and position size against current settings
- Fixed position size calculation: config stores collateral, positions store total value
- Added /validate command to Telegram bot for remote checking
- Returns detailed report of any mismatches with expected vs actual values
2025-10-27 19:20:36 +01:00
mindesbunister
eeb90ad455 Add documentation for duplicate position fix 2025-10-27 19:08:52 +01:00
mindesbunister
8f90339d8d Add duplicate position prevention to risk check
- Updated risk check API to verify no existing positions on same symbol
- Use getInitializedPositionManager() to wait for trade restoration
- Updated .dockerignore to exclude test files and archive/
- Moved test-*.ts files to archive directory
- Prevents multiple positions from being opened on same symbol even if signals are valid
2025-10-27 19:08:07 +01:00
mindesbunister
17b0806ff3 feat: Add 15-minute chart filter to n8n workflow
- Extract timeframe from TradingView message format: 'SOLUSDT.P 15'
- Added 'timeframe-filter' IF node after Parse Signal
- Only allows trades on 15-minute chart signals
- Blocks 5-minute and other timeframe signals
- Regex pattern: \.P\s+(\d+) matches '.P 15' format

This prevents bot from trading on wrong timeframe alerts.
2025-10-27 13:11:52 +01:00
mindesbunister
14d5de2c64 chore: Organize workspace structure - move docs, workflows, scripts to subdirectories
Organization:
- Created docs/ with setup/, guides/, history/ subdirectories
- Created workflows/ with trading/, analytics/, telegram/, archive/ subdirectories
- Created scripts/ with docker/, setup/, testing/ subdirectories
- Created tests/ for TypeScript test files
- Created archive/ for unused reference files

Moved files:
- 17 documentation files → docs/
- 16 workflow JSON files → workflows/
- 10 shell scripts → scripts/
- 4 test files → tests/
- 5 unused files → archive/

Updated:
- README.md with new file structure and documentation paths

Deleted:
- data/ (empty directory)
- screenshots/ (empty directory)

Critical files remain in root:
- telegram_command_bot.py (active bot - used by Dockerfile)
- watch-restart.sh (systemd service dependency)
- All Dockerfiles and docker-compose files
- All environment files

Validation:
 Containers running (trading-bot-v4, telegram-trade-bot, postgres)
 API responding (positions endpoint tested)
 Telegram bot functional (/status command tested)
 All critical files present in root

No code changes - purely organizational.
System continues running without interruption.

Recovery: git revert HEAD or git reset --hard cleanup-before
2025-10-27 12:59:25 +01:00
79 changed files with 4266 additions and 320 deletions

22
.env
View File

@@ -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
View 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
View 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

View 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
View File

@@ -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.cssTailwind 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.*

View 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>
)
}

View File

@@ -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>

View 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 }
)
}
}

View File

@@ -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) {

View File

@@ -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!')

View File

@@ -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()

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View File

@@ -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')

View File

@@ -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,

View 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 }
)
}
}

View File

@@ -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>

View File

@@ -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,
}
}

View File

@@ -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

View 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
View 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')
}
}

View File

@@ -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)
*/

View File

@@ -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)
*/

View File

@@ -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,

View 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)
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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
View 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"

View File

@@ -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))

View File

@@ -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"
},

View 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.100.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))

View File

@@ -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": {