feat: Position Manager persistence + order cleanup + improved stop loss

- Add Position Manager state persistence to survive restarts
  - Auto-restore open trades from database on startup
  - Save state after TP1, SL adjustments, profit locks
  - Persist to configSnapshot JSON field

- Add automatic order cancellation
  - Cancel all TP/SL orders when position fully closed
  - New cancelAllOrders() function in drift/orders.ts
  - Prevents orphaned orders after manual closes

- Improve stop loss management
  - Move SL to +0.35% after TP1 (was +0.15%)
  - Gives more breathing room for retracements
  - Still locks in half of TP1 profit

- Add database sync when Position Manager closes trades
  - Auto-update Trade record with exit data
  - Save P&L, exit reason, hold time
  - Fix analytics showing stale data

- Add trade state management functions
  - updateTradeState() for Position Manager persistence
  - getOpenTrades() for startup restoration
  - getInitializedPositionManager() for async init

- Create n8n database analytics workflows
  - Daily report workflow (automated at midnight)
  - Pattern analysis (hourly/daily performance)
  - Stop loss effectiveness analysis
  - Database analytics query workflow
  - Complete setup guide (N8N_DATABASE_SETUP.md)
This commit is contained in:
mindesbunister
2025-10-27 10:39:05 +01:00
parent f571d459e4
commit d3c04ea9c9
9 changed files with 1122 additions and 8 deletions

317
N8N_DATABASE_SETUP.md Normal file
View File

@@ -0,0 +1,317 @@
# n8n Database Integration Setup Guide
## Overview
This guide shows you how to connect your n8n instance to the Trading Bot v4 PostgreSQL database for automated analysis and insights.
## Database Connection Details
⚠️ **IMPORTANT:** n8n is on a **different Docker network** than the trading bot postgres. You MUST use the host machine IP or localhost.
### ✅ CORRECT Connection (n8n is on different network)
```
Type: PostgreSQL
Host: host.docker.internal (or your machine's IP like 172.18.0.1)
Port: 5432
Database: trading_bot_v4
User: postgres
Password: postgres
SSL Mode: disable
```
### Alternative: Use localhost with host networking
If `host.docker.internal` doesn't work, find your docker network gateway:
```bash
docker inspect n8n --format '{{range .NetworkSettings.Networks}}{{.Gateway}}{{end}}'
# Result: 172.18.0.1 (use this as Host)
```
### Network Details (for reference)
- **n8n network:** `compose_files_default` (172.18.0.0/16)
- **Trading bot network:** `traderv4_trading-net` (172.28.0.0/16)
- **PostgreSQL container:** `trading-bot-postgres` on traderv4_trading-net (172.28.0.2)
- **PostgreSQL exposed port:** 5432 → localhost:5432
Since they're on different networks, use the **host machine as bridge**.
## Setup Steps
### 1. Access n8n
Open your browser and navigate to:
```
http://localhost:8098
```
### 2. Create PostgreSQL Credential
1. Click on **Credentials** in the left sidebar
2. Click **Add Credential**
3. Search for **Postgres** and select it
4. Fill in the connection details (see above)
5. Name it: **Trading Bot Database**
6. Click **Test Connection** to verify
7. Click **Save**
### 3. Import Workflows
Four pre-built workflow templates are ready in your workspace:
#### A. Database Analytics (n8n-database-analytics.json)
**Purpose:** Query and analyze closed trades with statistical calculations
**Features:**
- Fetches last 100 closed trades
- Calculates win rate, P&L, profit factor
- Breaks down by symbol, direction, and exit reason
- Identifies best performing setups
**To import:**
1. Go to **Workflows****Add Workflow**
2. Click **...** menu → **Import from File**
3. Select `n8n-database-analytics.json`
4. Update PostgreSQL node to use "Trading Bot Database" credential
5. Click **Save**
6. Click **Execute Workflow** to test
#### B. Daily Trading Report (n8n-daily-report.json)
**Purpose:** Automated daily summary at midnight (stores in DailyStats table)
**Features:**
- Runs automatically at 00:05 every day
- Calculates yesterday's performance
- Breaks down by symbol
- Stores in DailyStats table for historical tracking
- Calculates win rate, profit factor, avg hold time
**To import:**
1. Import workflow from file
2. Update both PostgreSQL nodes with "Trading Bot Database" credential
3. **Activate** the workflow (toggle in top right)
4. Will run automatically at midnight
#### C. Pattern Analysis (n8n-pattern-analysis.json)
**Purpose:** Discover which times/conditions produce best results
**Features:**
- **Hourly Analysis:** Which hours have best win rate
- **Daily Analysis:** Which days perform best
- **Hold Time Analysis:** Optimal position duration
- Generates actionable recommendations
**Example insights:**
- "Focus trading around 14:00-16:00 (75% win rate)"
- "Trade more on Tuesday, avoid Friday"
- "Target exits in 15-30 min range"
**To import:**
1. Import workflow
2. Update all 3 PostgreSQL nodes with credential
3. Run manually to see insights
#### D. Stop Loss Analysis (n8n-stop-loss-analysis.json)
**Purpose:** Optimize stop loss distances and understand exit patterns
**Features:**
- Exit reason breakdown (stopped out vs targets hit)
- Stop distance effectiveness (tight vs wide stops)
- Symbol-specific stop performance
- Calculates profit factor (avg win / avg loss)
- Recommendations for stop optimization
**Example insights:**
- "⚠️ High stop hit rate - consider wider stops"
- "💡 Normal (1-1.5%) stops perform 45% better than tight stops"
- "✅ Risk/reward ratio is positive"
**To import:**
1. Import workflow
2. Update all 3 PostgreSQL nodes with credential
3. Run manually to analyze
## Database Schema Reference
### Trade Table (Main table)
Key fields for analysis:
```sql
id, symbol, direction, entryPrice, exitPrice, quantity,
notionalSize, realizedPnL, realizedPnLPercent,
entryTime, exitTime, holdTimeSeconds,
stopLossPrice, takeProfitPrice1, takeProfitPrice2,
exitReason, status, isTestTrade
```
### Common Queries
#### Get all closed trades (last 30 days)
```sql
SELECT * FROM "Trade"
WHERE status = 'closed'
AND "isTestTrade" = false
AND "entryTime" >= NOW() - INTERVAL '30 days'
ORDER BY "entryTime" DESC;
```
#### Calculate win rate
```sql
SELECT
COUNT(*) as total_trades,
COUNT(CASE WHEN "realizedPnL" > 0 THEN 1 END) as wins,
ROUND(COUNT(CASE WHEN "realizedPnL" > 0 THEN 1 END)::numeric / COUNT(*) * 100, 2) as win_rate
FROM "Trade"
WHERE status = 'closed' AND "isTestTrade" = false;
```
#### Best performing symbols
```sql
SELECT
symbol,
COUNT(*) as trades,
SUM("realizedPnL") as total_pnl,
AVG("realizedPnL") as avg_pnl
FROM "Trade"
WHERE status = 'closed' AND "isTestTrade" = false
GROUP BY symbol
ORDER BY total_pnl DESC;
```
## Workflow Automation Ideas
### 1. Performance Alerts
**Trigger:** Schedule (every 6 hours)
**Query:** Check if win rate drops below 50% in last 24h
**Action:** Send Telegram notification to pause trading
### 2. Best Setup Detector
**Trigger:** Manual or daily
**Query:** Find symbol + direction + time combinations with >70% win rate
**Action:** Save insights to config for bot to prioritize
### 3. Drawdown Monitor
**Trigger:** After each trade (webhook)
**Query:** Calculate rolling 10-trade P&L
**Action:** Auto-reduce position size if in drawdown
### 4. Exit Optimization
**Trigger:** Weekly
**Query:** Compare TP1 vs TP2 hit rates and P&L
**Action:** Recommend adjustment to TP levels
## Connecting Workflows to Trading Bot
### Webhook from Trading Bot to n8n
In your n8n workflow:
1. Add **Webhook** trigger node
2. Set HTTP Method: POST
3. Note the webhook URL: `http://localhost:8098/webhook/your-unique-id`
In trading bot code (e.g., after trade closes):
```typescript
// Send trade data to n8n for analysis
await fetch('http://localhost:8098/webhook/your-unique-id', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tradeId: trade.id,
symbol: trade.symbol,
pnl: trade.realizedPnL,
exitReason: trade.exitReason
})
});
```
### Update Bot Config from n8n
In your n8n workflow (after analysis):
1. Add **HTTP Request** node
2. URL: `http://trading-bot-v4:3000/api/settings`
3. Method: POST
4. Body: Updated config based on analysis
Example - adjust stop loss based on analysis:
```json
{
"STOP_LOSS_PERCENT": 1.5,
"USE_DUAL_STOPS": true
}
```
## Advanced Use Cases
### Machine Learning Integration
Use n8n to:
1. Export trade data to CSV
2. Send to Python ML service via HTTP
3. Receive predictions
4. Update bot configuration
### Multi-Timeframe Analysis
Create workflow that:
1. Queries trades by hour/day/week
2. Identifies patterns at each timeframe
3. Generates trading schedule recommendations
### Risk Management Automation
Build workflow that:
1. Monitors account balance
2. Calculates daily/weekly profit target
3. Auto-pauses bot after hitting target
4. Resumes trading next day/week
## Troubleshooting
### Connection Refused
- Verify PostgreSQL container is running: `docker ps | grep postgres`
- Check port mapping: `docker port trading-bot-postgres`
- Ensure n8n and postgres are on same Docker network
### Query Timeouts
- Add indexes to frequently queried columns
- Limit result sets with `LIMIT` clause
- Use date range filters to reduce dataset
### Empty Results
- Check `isTestTrade` filter (you may want to include test trades for testing)
- Verify date range in queries
- Ensure trades have been closed (`status = 'closed'`)
## Testing Your Setup
### Quick Test Query
In n8n, create a workflow with:
1. Manual Trigger
2. PostgreSQL node with query:
```sql
SELECT COUNT(*) as total_trades,
MAX("entryTime") as last_trade
FROM "Trade";
```
3. Execute and verify results
### Verify Test Trade Flag
```sql
SELECT
"isTestTrade",
COUNT(*) as count
FROM "Trade"
GROUP BY "isTestTrade";
```
Expected output:
- `false`: Production trades
- `true`: Test trades from /api/trading/test-db
## Next Steps
1. **Import all 4 workflows** and test each one
2. **Activate the Daily Report** workflow for automated tracking
3. **Run Pattern Analysis** to discover your best trading times
4. **Run Stop Loss Analysis** to optimize risk management
5. **Create custom workflows** based on your specific needs
## Support
If you encounter issues:
1. Check n8n logs: `docker logs n8n`
2. Check postgres logs: `docker logs trading-bot-postgres`
3. Test database connection from host: `psql -h localhost -p 5432 -U postgres -d trading_bot_v4`
4. Verify bot is writing to database: Check `/analytics` page in web UI
---
**Pro Tip:** Start with the Pattern Analysis workflow to understand your trading patterns, then use those insights to create automated optimization workflows!

View File

@@ -10,7 +10,7 @@ import { initializeDriftService } from '@/lib/drift/client'
import { openPosition, placeExitOrders } from '@/lib/drift/orders' import { openPosition, placeExitOrders } from '@/lib/drift/orders'
import { normalizeTradingViewSymbol } from '@/config/trading' import { normalizeTradingViewSymbol } from '@/config/trading'
import { getMergedConfig } 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' import { createTrade } from '@/lib/database/trades'
export interface ExecuteTradeRequest { export interface ExecuteTradeRequest {
@@ -209,7 +209,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
} }
// Add to position manager for monitoring // Add to position manager for monitoring
const positionManager = getPositionManager() const positionManager = await getInitializedPositionManager()
await positionManager.addTrade(activeTrade) await positionManager.addTrade(activeTrade)
console.log('✅ Trade added to position manager for monitoring') console.log('✅ Trade added to position manager for monitoring')

View File

@@ -45,6 +45,19 @@ export interface CreateTradeParams {
isTestTrade?: boolean isTestTrade?: boolean
} }
export interface UpdateTradeStateParams {
positionId: string
currentSize: number
tp1Hit: boolean
slMovedToBreakeven: boolean
slMovedToProfit: boolean
stopLossPrice: number
realizedPnL: number
unrealizedPnL: number
peakPnL: number
lastPrice: number
}
export interface UpdateTradeExitParams { export interface UpdateTradeExitParams {
positionId: string positionId: string
exitPrice: number exitPrice: number
@@ -144,6 +157,66 @@ export async function updateTradeExit(params: UpdateTradeExitParams) {
} }
} }
/**
* Update active trade state (for Position Manager persistence)
*/
export async function updateTradeState(params: UpdateTradeStateParams) {
const prisma = getPrismaClient()
try {
const trade = await prisma.trade.update({
where: { positionId: params.positionId },
data: {
// Store Position Manager state in configSnapshot
configSnapshot: {
...(await prisma.trade.findUnique({
where: { positionId: params.positionId },
select: { configSnapshot: true }
}))?.configSnapshot as any,
// Add Position Manager state
positionManagerState: {
currentSize: params.currentSize,
tp1Hit: params.tp1Hit,
slMovedToBreakeven: params.slMovedToBreakeven,
slMovedToProfit: params.slMovedToProfit,
stopLossPrice: params.stopLossPrice,
realizedPnL: params.realizedPnL,
unrealizedPnL: params.unrealizedPnL,
peakPnL: params.peakPnL,
lastPrice: params.lastPrice,
lastUpdate: new Date().toISOString(),
}
}
},
})
return trade
} catch (error) {
console.error('❌ Failed to update trade state:', error)
// Don't throw - state updates are non-critical
}
}
/**
* Get all open trades (for Position Manager recovery)
*/
export async function getOpenTrades() {
const prisma = getPrismaClient()
try {
const trades = await prisma.trade.findMany({
where: { status: 'open' },
orderBy: { entryTime: 'asc' },
})
console.log(`📊 Found ${trades.length} open trades to restore`)
return trades
} catch (error) {
console.error('❌ Failed to get open trades:', error)
return []
}
}
/** /**
* Add price update for a trade (for tracking max gain/drawdown) * Add price update for a trade (for tracking max gain/drawdown)
*/ */

View File

@@ -498,6 +498,15 @@ export async function closePosition(
console.log(` Close price: $${oraclePrice.toFixed(4)}`) console.log(` Close price: $${oraclePrice.toFixed(4)}`)
console.log(` Realized P&L: $${realizedPnL.toFixed(2)}`) console.log(` Realized P&L: $${realizedPnL.toFixed(2)}`)
// If closing 100%, cancel all remaining orders for this market
if (params.percentToClose === 100) {
console.log('🗑️ Position fully closed, cancelling remaining orders...')
const cancelResult = await cancelAllOrders(params.symbol)
if (cancelResult.success && cancelResult.cancelledCount! > 0) {
console.log(`✅ Cancelled ${cancelResult.cancelledCount} orders`)
}
}
return { return {
success: true, success: true,
transactionSignature: txSig, transactionSignature: txSig,
@@ -515,6 +524,68 @@ export async function closePosition(
} }
} }
/**
* Cancel all open orders for a specific market
*/
export async function cancelAllOrders(
symbol: string
): Promise<{ success: boolean; cancelledCount?: number; error?: string }> {
try {
console.log(`🗑️ Cancelling all orders for ${symbol}...`)
const driftService = getDriftService()
const driftClient = driftService.getClient()
const marketConfig = getMarketConfig(symbol)
const isDryRun = process.env.DRY_RUN === 'true'
if (isDryRun) {
console.log('🧪 DRY RUN: Simulating order cancellation')
return { success: true, cancelledCount: 0 }
}
// Get user account to check for orders
const userAccount = driftClient.getUserAccount()
if (!userAccount) {
throw new Error('User account not found')
}
// Filter orders for this market
const ordersToCancel = userAccount.orders.filter(
(order: any) =>
order.marketIndex === marketConfig.driftMarketIndex &&
order.status === 0 // 0 = Open status
)
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`)
// Cancel all orders for this market
const txSig = await driftClient.cancelOrders(
undefined, // Cancel by market type
marketConfig.driftMarketIndex,
undefined // No specific direction filter
)
console.log(`✅ Orders cancelled! Transaction: ${txSig}`)
return {
success: true,
cancelledCount: ordersToCancel.length,
}
} catch (error) {
console.error('❌ Failed to cancel orders:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
/** /**
* Close entire position for a market * Close entire position for a market
*/ */

View File

@@ -8,6 +8,7 @@ import { getDriftService } from '../drift/client'
import { closePosition } from '../drift/orders' import { closePosition } from '../drift/orders'
import { getPythPriceMonitor, PriceUpdate } from '../pyth/price-monitor' import { getPythPriceMonitor, PriceUpdate } from '../pyth/price-monitor'
import { getMergedConfig, TradingConfig } from '../../config/trading' import { getMergedConfig, TradingConfig } from '../../config/trading'
import { updateTradeExit, updateTradeState, getOpenTrades } from '../database/trades'
export interface ActiveTrade { export interface ActiveTrade {
id: string id: string
@@ -46,7 +47,7 @@ export interface ActiveTrade {
export interface ExitResult { export interface ExitResult {
success: boolean success: boolean
reason: 'TP1' | 'TP2' | 'SL' | 'emergency' | 'manual' | 'error' reason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'emergency' | 'manual' | 'error'
closePrice?: number closePrice?: number
closedSize?: number closedSize?: number
realizedPnL?: number realizedPnL?: number
@@ -58,12 +59,74 @@ export class PositionManager {
private activeTrades: Map<string, ActiveTrade> = new Map() private activeTrades: Map<string, ActiveTrade> = new Map()
private config: TradingConfig private config: TradingConfig
private isMonitoring: boolean = false private isMonitoring: boolean = false
private initialized: boolean = false
constructor(config?: Partial<TradingConfig>) { constructor(config?: Partial<TradingConfig>) {
this.config = getMergedConfig(config) this.config = getMergedConfig(config)
console.log('✅ Position manager created') console.log('✅ Position manager created')
} }
/**
* Initialize and restore active trades from database
*/
async initialize(): Promise<void> {
if (this.initialized) {
return
}
console.log('🔄 Restoring active trades from database...')
try {
const openTrades = await getOpenTrades()
for (const dbTrade of openTrades) {
// Extract Position Manager state from configSnapshot
const pmState = (dbTrade.configSnapshot as any)?.positionManagerState
// Reconstruct ActiveTrade object
const activeTrade: ActiveTrade = {
id: dbTrade.id,
positionId: dbTrade.positionId,
symbol: dbTrade.symbol,
direction: dbTrade.direction as 'long' | 'short',
entryPrice: dbTrade.entryPrice,
entryTime: dbTrade.entryTime.getTime(),
positionSize: dbTrade.positionSizeUSD,
leverage: dbTrade.leverage,
stopLossPrice: pmState?.stopLossPrice ?? dbTrade.stopLossPrice,
tp1Price: dbTrade.takeProfit1Price,
tp2Price: dbTrade.takeProfit2Price,
emergencyStopPrice: dbTrade.stopLossPrice * (dbTrade.direction === 'long' ? 0.98 : 1.02),
currentSize: pmState?.currentSize ?? dbTrade.positionSizeUSD,
tp1Hit: pmState?.tp1Hit ?? false,
slMovedToBreakeven: pmState?.slMovedToBreakeven ?? false,
slMovedToProfit: pmState?.slMovedToProfit ?? false,
realizedPnL: pmState?.realizedPnL ?? 0,
unrealizedPnL: pmState?.unrealizedPnL ?? 0,
peakPnL: pmState?.peakPnL ?? 0,
priceCheckCount: 0,
lastPrice: pmState?.lastPrice ?? dbTrade.entryPrice,
lastUpdateTime: Date.now(),
}
this.activeTrades.set(activeTrade.id, activeTrade)
console.log(`✅ Restored trade: ${activeTrade.symbol} ${activeTrade.direction} at $${activeTrade.entryPrice}`)
}
if (this.activeTrades.size > 0) {
console.log(`🎯 Restored ${this.activeTrades.size} active trades`)
await this.startMonitoring()
} else {
console.log('✅ No active trades to restore')
}
} catch (error) {
console.error('❌ Failed to restore active trades:', error)
}
this.initialized = true
}
/** /**
* Add a new trade to monitor * Add a new trade to monitor
*/ */
@@ -72,6 +135,9 @@ export class PositionManager {
this.activeTrades.set(trade.id, trade) this.activeTrades.set(trade.id, trade)
// Save initial state to database
await this.saveTradeState(trade)
console.log(`✅ Trade added. Active trades: ${this.activeTrades.size}`) console.log(`✅ Trade added. Active trades: ${this.activeTrades.size}`)
// Start monitoring if not already running // Start monitoring if not already running
@@ -238,17 +304,20 @@ export class PositionManager {
console.log(`🎉 TP1 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`) console.log(`🎉 TP1 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
await this.executeExit(trade, 50, 'TP1', currentPrice) await this.executeExit(trade, 50, 'TP1', currentPrice)
// Move SL to breakeven // Move SL to secure profit after TP1
trade.tp1Hit = true trade.tp1Hit = true
trade.currentSize = trade.positionSize * 0.5 trade.currentSize = trade.positionSize * 0.5
trade.stopLossPrice = this.calculatePrice( trade.stopLossPrice = this.calculatePrice(
trade.entryPrice, trade.entryPrice,
0.15, // +0.15% to cover fees 0.35, // +0.35% to secure profit and avoid stop-out on retracement
trade.direction trade.direction
) )
trade.slMovedToBreakeven = true trade.slMovedToBreakeven = true
console.log(`🔒 SL moved to breakeven: ${trade.stopLossPrice.toFixed(4)}`) console.log(`🔒 SL moved to +0.35% (half of TP1): ${trade.stopLossPrice.toFixed(4)}`)
// Save state after TP1
await this.saveTradeState(trade)
return return
} }
@@ -268,6 +337,9 @@ export class PositionManager {
trade.slMovedToProfit = true trade.slMovedToProfit = true
console.log(`🎯 SL moved to +${this.config.profitLockPercent}%: ${trade.stopLossPrice.toFixed(4)}`) console.log(`🎯 SL moved to +${this.config.profitLockPercent}%: ${trade.stopLossPrice.toFixed(4)}`)
// Save state after profit lock
await this.saveTradeState(trade)
} }
// 5. Take profit 2 (remaining 50%) // 5. Take profit 2 (remaining 50%)
@@ -305,8 +377,29 @@ export class PositionManager {
if (percentToClose >= 100) { if (percentToClose >= 100) {
// Full close - remove from monitoring // Full close - remove from monitoring
trade.realizedPnL += result.realizedPnL || 0 trade.realizedPnL += result.realizedPnL || 0
this.removeTrade(trade.id)
// Save to database (only for valid exit reasons)
if (reason !== 'error') {
try {
const holdTimeSeconds = Math.floor((Date.now() - trade.entryTime) / 1000)
await updateTradeExit({
positionId: trade.positionId,
exitPrice: result.closePrice || currentPrice,
exitReason: reason as 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'manual' | 'emergency',
realizedPnL: trade.realizedPnL,
exitOrderTx: result.transactionSignature || 'MANUAL_CLOSE',
holdTimeSeconds,
maxDrawdown: 0, // TODO: Track this
maxGain: trade.peakPnL,
})
console.log('💾 Trade saved to database')
} catch (dbError) {
console.error('❌ Failed to save trade exit to database:', dbError)
// Don't fail the close if database fails
}
}
this.removeTrade(trade.id)
console.log(`✅ Position closed | P&L: $${trade.realizedPnL.toFixed(2)} | Reason: ${reason}`) console.log(`✅ Position closed | P&L: $${trade.realizedPnL.toFixed(2)} | Reason: ${reason}`)
} else { } else {
// Partial close (TP1) // Partial close (TP1)
@@ -316,7 +409,6 @@ export class PositionManager {
console.log(`✅ 50% closed | Realized: $${result.realizedPnL?.toFixed(2)} | Remaining: ${trade.currentSize}`) console.log(`✅ 50% closed | Realized: $${result.realizedPnL?.toFixed(2)} | Remaining: ${trade.currentSize}`)
} }
// TODO: Save to database
// TODO: Send notification // TODO: Send notification
} catch (error) { } catch (error) {
@@ -404,6 +496,29 @@ export class PositionManager {
console.log('✅ All positions closed') console.log('✅ All positions closed')
} }
/**
* Save trade state to database (for persistence across restarts)
*/
private async saveTradeState(trade: ActiveTrade): Promise<void> {
try {
await updateTradeState({
positionId: trade.positionId,
currentSize: trade.currentSize,
tp1Hit: trade.tp1Hit,
slMovedToBreakeven: trade.slMovedToBreakeven,
slMovedToProfit: trade.slMovedToProfit,
stopLossPrice: trade.stopLossPrice,
realizedPnL: trade.realizedPnL,
unrealizedPnL: trade.unrealizedPnL,
peakPnL: trade.peakPnL,
lastPrice: trade.lastPrice,
})
} catch (error) {
console.error('❌ Failed to save trade state:', error)
// Don't throw - state save is non-critical
}
}
/** /**
* Get monitoring status * Get monitoring status
*/ */
@@ -426,10 +541,26 @@ export class PositionManager {
// Singleton instance // Singleton instance
let positionManagerInstance: PositionManager | null = null let positionManagerInstance: PositionManager | null = null
let initPromise: Promise<void> | null = null
export function getPositionManager(): PositionManager { export function getPositionManager(): PositionManager {
if (!positionManagerInstance) { if (!positionManagerInstance) {
positionManagerInstance = new PositionManager() positionManagerInstance = new PositionManager()
// Initialize asynchronously (restore trades from database)
if (!initPromise) {
initPromise = positionManagerInstance.initialize().catch(error => {
console.error('❌ Failed to initialize Position Manager:', error)
})
}
} }
return positionManagerInstance return positionManagerInstance
} }
export async function getInitializedPositionManager(): Promise<PositionManager> {
const manager = getPositionManager()
if (initPromise) {
await initPromise
}
return manager
}

171
n8n-daily-report.json Normal file
View File

@@ -0,0 +1,171 @@
{
"name": "Daily Trading Report",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"triggerAtHour": 0,
"triggerAtMinute": 5
}
]
}
},
"id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"name": "Every Day at Midnight",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [240, 300]
},
{
"parameters": {
"operation": "executeQuery",
"query": "-- Get yesterday's trading activity\nSELECT \n COUNT(*) as total_trades,\n COUNT(CASE WHEN \"realizedPnL\" > 0 THEN 1 END) as winning_trades,\n COUNT(CASE WHEN \"realizedPnL\" < 0 THEN 1 END) as losing_trades,\n SUM(\"realizedPnL\") as total_pnl,\n AVG(\"realizedPnL\") as avg_pnl,\n MAX(\"realizedPnL\") as best_trade,\n MIN(\"realizedPnL\") as worst_trade,\n AVG(\"holdTimeSeconds\") / 60 as avg_hold_time_minutes\nFROM \"Trade\"\nWHERE status = 'closed'\n AND \"isTestTrade\" = false\n AND \"exitTime\" >= CURRENT_DATE - INTERVAL '1 day'\n AND \"exitTime\" < CURRENT_DATE;"
},
"id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
"name": "Query Yesterday Stats",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.4,
"position": [460, 300],
"credentials": {
"postgres": {
"id": "1",
"name": "Trading Bot Database"
}
}
},
{
"parameters": {
"operation": "executeQuery",
"query": "-- Get breakdown by symbol\nSELECT \n symbol,\n COUNT(*) as trades,\n SUM(\"realizedPnL\") as pnl\nFROM \"Trade\"\nWHERE status = 'closed'\n AND \"isTestTrade\" = false\n AND \"exitTime\" >= CURRENT_DATE - INTERVAL '1 day'\n AND \"exitTime\" < CURRENT_DATE\nGROUP BY symbol\nORDER BY pnl DESC;"
},
"id": "3c4d5e6f-7a8b-9c0d-1e2f-3a4b5c6d7e8f",
"name": "Query Symbol Breakdown",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.4,
"position": [460, 500],
"credentials": {
"postgres": {
"id": "1",
"name": "Trading Bot Database"
}
}
},
{
"parameters": {
"operation": "insert",
"schema": {
"__rl": true,
"value": "public",
"mode": "list",
"cachedResultName": "public"
},
"table": {
"__rl": true,
"value": "DailyStats",
"mode": "list",
"cachedResultName": "DailyStats"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"date": "={{ $json.date }}",
"tradesCount": "={{ $json.total_trades }}",
"winningTrades": "={{ $json.winning_trades }}",
"losingTrades": "={{ $json.losing_trades }}",
"totalPnL": "={{ $json.total_pnl }}",
"totalPnLPercent": "0",
"winRate": "={{ $json.win_rate }}",
"avgWin": "={{ $json.avg_win }}",
"avgLoss": "={{ $json.avg_loss }}",
"profitFactor": "={{ $json.profit_factor }}",
"maxDrawdown": "0",
"sharpeRatio": "0"
}
}
},
"id": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a",
"name": "Save Daily Stats",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.4,
"position": [900, 300],
"credentials": {
"postgres": {
"id": "1",
"name": "Trading Bot Database"
}
}
},
{
"parameters": {
"jsCode": "const stats = $('Query Yesterday Stats').first().json;\nconst symbols = $('Query Symbol Breakdown').all();\n\nconst winRate = stats.total_trades > 0 ? (stats.winning_trades / stats.total_trades) * 100 : 0;\nconst avgWin = stats.winning_trades > 0 ? stats.total_pnl / stats.winning_trades : 0;\nconst avgLoss = stats.losing_trades > 0 ? Math.abs(stats.total_pnl / stats.losing_trades) : 0;\nconst profitFactor = avgLoss !== 0 ? avgWin / avgLoss : 0;\n\nconst yesterday = new Date();\nyesterday.setDate(yesterday.getDate() - 1);\nyesterday.setHours(0, 0, 0, 0);\n\nreturn [{\n json: {\n date: yesterday.toISOString(),\n total_trades: parseInt(stats.total_trades) || 0,\n winning_trades: parseInt(stats.winning_trades) || 0,\n losing_trades: parseInt(stats.losing_trades) || 0,\n total_pnl: parseFloat(stats.total_pnl) || 0,\n win_rate: winRate,\n avg_win: avgWin,\n avg_loss: avgLoss,\n profit_factor: profitFactor,\n symbols: symbols.map(s => s.json)\n }\n}];"
},
"id": "5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0b",
"name": "Process Data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [680, 300]
}
],
"connections": {
"Every Day at Midnight": {
"main": [
[
{
"node": "Query Yesterday Stats",
"type": "main",
"index": 0
},
{
"node": "Query Symbol Breakdown",
"type": "main",
"index": 0
}
]
]
},
"Query Yesterday Stats": {
"main": [
[
{
"node": "Process Data",
"type": "main",
"index": 0
}
]
]
},
"Query Symbol Breakdown": {
"main": [
[
{
"node": "Process Data",
"type": "main",
"index": 0
}
]
]
},
"Process Data": {
"main": [
[
{
"node": "Save Daily Stats",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [],
"triggerCount": 0,
"updatedAt": "2025-10-27T00:00:00.000Z",
"versionId": "1"
}

View File

@@ -0,0 +1,73 @@
{
"name": "Trading Database Analytics",
"nodes": [
{
"parameters": {},
"id": "7c2dbef4-8f5f-4c0e-9f3c-4e5c5d8f2a1b",
"name": "When clicking 'Test workflow'",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [240, 300]
},
{
"parameters": {
"operation": "executeQuery",
"query": "-- Get all closed trades with performance metrics\nSELECT \n id,\n symbol,\n direction,\n \"entryPrice\",\n \"exitPrice\",\n \"positionSizeUSD\",\n leverage,\n \"realizedPnL\",\n \"realizedPnLPercent\",\n \"holdTimeSeconds\",\n \"exitReason\",\n \"entryTime\",\n \"exitTime\",\n \"isTestTrade\",\n CASE \n WHEN \"realizedPnL\" > 0 THEN 'WIN'\n WHEN \"realizedPnL\" < 0 THEN 'LOSS'\n ELSE 'BREAKEVEN'\n END as trade_result\nFROM \"Trade\"\nWHERE status = 'closed'\n AND \"isTestTrade\" = false\nORDER BY \"exitTime\" DESC\nLIMIT 100;"
},
"id": "3f9c1d7b-2e4a-4c8f-9b1e-6d8e5c3f2a4b",
"name": "Query Closed Trades",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.4,
"position": [460, 300],
"credentials": {
"postgres": {
"id": "1",
"name": "Trading Bot Database"
}
}
},
{
"parameters": {
"jsCode": "// Calculate trading statistics from closed trades\nconst trades = $input.all();\n\nif (trades.length === 0) {\n return [{\n json: {\n message: 'No closed trades found',\n totalTrades: 0\n }\n }];\n}\n\nconst wins = trades.filter(t => t.json.trade_result === 'WIN');\nconst losses = trades.filter(t => t.json.trade_result === 'LOSS');\n\nconst totalPnL = trades.reduce((sum, t) => sum + parseFloat(t.json.realizedPnL || 0), 0);\nconst avgWin = wins.length > 0 ? wins.reduce((sum, t) => sum + parseFloat(t.json.realizedPnL), 0) / wins.length : 0;\nconst avgLoss = losses.length > 0 ? losses.reduce((sum, t) => sum + parseFloat(t.json.realizedPnL), 0) / losses.length : 0;\nconst winRate = (wins.length / trades.length) * 100;\nconst profitFactor = avgLoss !== 0 ? Math.abs(avgWin / avgLoss) : 0;\n\n// Calculate average hold time\nconst avgHoldTime = trades.reduce((sum, t) => sum + parseInt(t.json.holdTimeSeconds || 0), 0) / trades.length;\n\n// Group by symbol\nconst bySymbol = {};\ntrades.forEach(t => {\n const symbol = t.json.symbol;\n if (!bySymbol[symbol]) {\n bySymbol[symbol] = { trades: 0, wins: 0, pnl: 0 };\n }\n bySymbol[symbol].trades++;\n if (t.json.trade_result === 'WIN') bySymbol[symbol].wins++;\n bySymbol[symbol].pnl += parseFloat(t.json.realizedPnL || 0);\n});\n\n// Group by direction\nconst longs = trades.filter(t => t.json.direction === 'long');\nconst shorts = trades.filter(t => t.json.direction === 'short');\n\nconst longWins = longs.filter(t => t.json.trade_result === 'WIN').length;\nconst shortWins = shorts.filter(t => t.json.trade_result === 'WIN').length;\n\n// Group by exit reason\nconst exitReasons = {};\ntrades.forEach(t => {\n const reason = t.json.exitReason || 'unknown';\n if (!exitReasons[reason]) {\n exitReasons[reason] = { count: 0, pnl: 0 };\n }\n exitReasons[reason].count++;\n exitReasons[reason].pnl += parseFloat(t.json.realizedPnL || 0);\n});\n\nreturn [{\n json: {\n summary: {\n totalTrades: trades.length,\n winningTrades: wins.length,\n losingTrades: losses.length,\n winRate: winRate.toFixed(2) + '%',\n totalPnL: '$' + totalPnL.toFixed(2),\n avgWin: '$' + avgWin.toFixed(2),\n avgLoss: '$' + avgLoss.toFixed(2),\n profitFactor: profitFactor.toFixed(2),\n avgHoldTimeMinutes: (avgHoldTime / 60).toFixed(1)\n },\n bySymbol,\n byDirection: {\n long: {\n total: longs.length,\n wins: longWins,\n winRate: longs.length > 0 ? ((longWins / longs.length) * 100).toFixed(2) + '%' : '0%'\n },\n short: {\n total: shorts.length,\n wins: shortWins,\n winRate: shorts.length > 0 ? ((shortWins / shorts.length) * 100).toFixed(2) + '%' : '0%'\n }\n },\n exitReasons,\n timestamp: new Date().toISOString()\n }\n}];"
},
"id": "8a3b4c5d-6e7f-4a8b-9c0d-1e2f3a4b5c6d",
"name": "Calculate Statistics",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [680, 300]
}
],
"connections": {
"When clicking 'Test workflow'": {
"main": [
[
{
"node": "Query Closed Trades",
"type": "main",
"index": 0
}
]
]
},
"Query Closed Trades": {
"main": [
[
{
"node": "Calculate Statistics",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [],
"triggerCount": 0,
"updatedAt": "2025-10-27T00:00:00.000Z",
"versionId": "1"
}

139
n8n-pattern-analysis.json Normal file
View File

@@ -0,0 +1,139 @@
{
"name": "Pattern Analysis - Win Rate by Hour",
"nodes": [
{
"parameters": {},
"id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [240, 300]
},
{
"parameters": {
"operation": "executeQuery",
"query": "-- Win rate analysis by hour of day\nSELECT \n EXTRACT(HOUR FROM \"entryTime\") as hour,\n COUNT(*) as total_trades,\n COUNT(CASE WHEN \"realizedPnL\" > 0 THEN 1 END) as wins,\n COUNT(CASE WHEN \"realizedPnL\" < 0 THEN 1 END) as losses,\n ROUND(COUNT(CASE WHEN \"realizedPnL\" > 0 THEN 1 END)::numeric / NULLIF(COUNT(*), 0) * 100, 2) as win_rate_pct,\n ROUND(SUM(\"realizedPnL\")::numeric, 2) as total_pnl,\n ROUND(AVG(\"realizedPnL\")::numeric, 4) as avg_pnl\nFROM \"Trade\"\nWHERE status = 'closed'\n AND \"isTestTrade\" = false\n AND \"entryTime\" >= NOW() - INTERVAL '30 days'\nGROUP BY EXTRACT(HOUR FROM \"entryTime\")\nORDER BY hour;"
},
"id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
"name": "Query Hourly Performance",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.4,
"position": [460, 300],
"credentials": {
"postgres": {
"id": "1",
"name": "Trading Bot Database"
}
}
},
{
"parameters": {
"operation": "executeQuery",
"query": "-- Win rate by day of week\nSELECT \n EXTRACT(DOW FROM \"entryTime\") as day_of_week,\n CASE EXTRACT(DOW FROM \"entryTime\")\n WHEN 0 THEN 'Sunday'\n WHEN 1 THEN 'Monday'\n WHEN 2 THEN 'Tuesday'\n WHEN 3 THEN 'Wednesday'\n WHEN 4 THEN 'Thursday'\n WHEN 5 THEN 'Friday'\n WHEN 6 THEN 'Saturday'\n END as day_name,\n COUNT(*) as total_trades,\n COUNT(CASE WHEN \"realizedPnL\" > 0 THEN 1 END) as wins,\n ROUND(COUNT(CASE WHEN \"realizedPnL\" > 0 THEN 1 END)::numeric / NULLIF(COUNT(*), 0) * 100, 2) as win_rate_pct,\n ROUND(SUM(\"realizedPnL\")::numeric, 2) as total_pnl\nFROM \"Trade\"\nWHERE status = 'closed'\n AND \"isTestTrade\" = false\n AND \"entryTime\" >= NOW() - INTERVAL '30 days'\nGROUP BY EXTRACT(DOW FROM \"entryTime\")\nORDER BY day_of_week;"
},
"id": "3c4d5e6f-7a8b-9c0d-1e2f-3a4b5c6d7e8f",
"name": "Query Daily Performance",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.4,
"position": [460, 500],
"credentials": {
"postgres": {
"id": "1",
"name": "Trading Bot Database"
}
}
},
{
"parameters": {
"operation": "executeQuery",
"query": "-- Hold time vs profitability\nSELECT \n CASE \n WHEN \"holdTimeSeconds\" < 300 THEN '0-5 min'\n WHEN \"holdTimeSeconds\" < 900 THEN '5-15 min'\n WHEN \"holdTimeSeconds\" < 1800 THEN '15-30 min'\n WHEN \"holdTimeSeconds\" < 3600 THEN '30-60 min'\n WHEN \"holdTimeSeconds\" < 7200 THEN '1-2 hours'\n ELSE '2+ hours'\n END as hold_time_bucket,\n COUNT(*) as trades,\n COUNT(CASE WHEN \"realizedPnL\" > 0 THEN 1 END) as wins,\n ROUND(COUNT(CASE WHEN \"realizedPnL\" > 0 THEN 1 END)::numeric / NULLIF(COUNT(*), 0) * 100, 2) as win_rate_pct,\n ROUND(AVG(\"realizedPnL\")::numeric, 4) as avg_pnl,\n ROUND(SUM(\"realizedPnL\")::numeric, 2) as total_pnl\nFROM \"Trade\"\nWHERE status = 'closed'\n AND \"isTestTrade\" = false\n AND \"holdTimeSeconds\" IS NOT NULL\n AND \"entryTime\" >= NOW() - INTERVAL '30 days'\nGROUP BY \n CASE \n WHEN \"holdTimeSeconds\" < 300 THEN '0-5 min'\n WHEN \"holdTimeSeconds\" < 900 THEN '5-15 min'\n WHEN \"holdTimeSeconds\" < 1800 THEN '15-30 min'\n WHEN \"holdTimeSeconds\" < 3600 THEN '30-60 min'\n WHEN \"holdTimeSeconds\" < 7200 THEN '1-2 hours'\n ELSE '2+ hours'\n END\nORDER BY \n CASE \n WHEN hold_time_bucket = '0-5 min' THEN 1\n WHEN hold_time_bucket = '5-15 min' THEN 2\n WHEN hold_time_bucket = '15-30 min' THEN 3\n WHEN hold_time_bucket = '30-60 min' THEN 4\n WHEN hold_time_bucket = '1-2 hours' THEN 5\n ELSE 6\n END;"
},
"id": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a",
"name": "Query Hold Time Analysis",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.4,
"position": [460, 700],
"credentials": {
"postgres": {
"id": "1",
"name": "Trading Bot Database"
}
}
},
{
"parameters": {
"jsCode": "const hourly = $('Query Hourly Performance').all().map(item => item.json);\nconst daily = $('Query Daily Performance').all().map(item => item.json);\nconst holdTime = $('Query Hold Time Analysis').all().map(item => item.json);\n\n// Find best and worst hours\nconst sortedHours = [...hourly].sort((a, b) => b.win_rate_pct - a.win_rate_pct);\nconst bestHours = sortedHours.slice(0, 3);\nconst worstHours = sortedHours.slice(-3).reverse();\n\n// Find best day\nconst sortedDays = [...daily].sort((a, b) => b.win_rate_pct - a.win_rate_pct);\nconst bestDay = sortedDays[0];\nconst worstDay = sortedDays[sortedDays.length - 1];\n\n// Find optimal hold time\nconst sortedHoldTime = [...holdTime].sort((a, b) => b.avg_pnl - a.avg_pnl);\nconst optimalHoldTime = sortedHoldTime[0];\n\n// Generate insights\nconst insights = {\n hourly: {\n bestHours: bestHours.map(h => `${h.hour}:00 (${h.win_rate_pct}% win rate, ${h.total_trades} trades)`),\n worstHours: worstHours.map(h => `${h.hour}:00 (${h.win_rate_pct}% win rate, ${h.total_trades} trades)`),\n recommendation: bestHours.length > 0 ? `Focus trading around ${bestHours[0].hour}:00-${bestHours[2].hour}:00` : 'Need more data'\n },\n daily: {\n bestDay: bestDay ? `${bestDay.day_name} (${bestDay.win_rate_pct}% win rate)` : 'N/A',\n worstDay: worstDay ? `${worstDay.day_name} (${worstDay.win_rate_pct}% win rate)` : 'N/A',\n recommendation: bestDay && worstDay ? `Trade more on ${bestDay.day_name}, avoid ${worstDay.day_name}` : 'Need more data'\n },\n holdTime: {\n optimal: optimalHoldTime ? `${optimalHoldTime.hold_time_bucket} (avg P&L: $${optimalHoldTime.avg_pnl})` : 'N/A',\n recommendation: optimalHoldTime ? `Target exits in ${optimalHoldTime.hold_time_bucket} range` : 'Need more data'\n },\n rawData: {\n hourly,\n daily,\n holdTime\n }\n};\n\nreturn [{ json: insights }];"
},
"id": "5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0b",
"name": "Generate Insights",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [680, 300]
}
],
"connections": {
"Manual Trigger": {
"main": [
[
{
"node": "Query Hourly Performance",
"type": "main",
"index": 0
},
{
"node": "Query Daily Performance",
"type": "main",
"index": 0
},
{
"node": "Query Hold Time Analysis",
"type": "main",
"index": 0
}
]
]
},
"Query Hourly Performance": {
"main": [
[
{
"node": "Generate Insights",
"type": "main",
"index": 0
}
]
]
},
"Query Daily Performance": {
"main": [
[
{
"node": "Generate Insights",
"type": "main",
"index": 0
}
]
]
},
"Query Hold Time Analysis": {
"main": [
[
{
"node": "Generate Insights",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [],
"triggerCount": 0,
"updatedAt": "2025-10-27T00:00:00.000Z",
"versionId": "1"
}

139
n8n-stop-loss-analysis.json Normal file
View File

@@ -0,0 +1,139 @@
{
"name": "Stop Loss Analysis - Which Stops Get Hit",
"nodes": [
{
"parameters": {},
"id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [240, 300]
},
{
"parameters": {
"operation": "executeQuery",
"query": "-- Exit reason breakdown\nSELECT \n \"exitReason\",\n COUNT(*) as count,\n ROUND(COUNT(*)::numeric / (SELECT COUNT(*) FROM \"Trade\" WHERE status = 'closed' AND \"isTestTrade\" = false) * 100, 2) as percentage,\n ROUND(AVG(\"realizedPnL\")::numeric, 4) as avg_pnl,\n ROUND(SUM(\"realizedPnL\")::numeric, 2) as total_pnl,\n ROUND(AVG(\"holdTimeSeconds\")::numeric / 60, 1) as avg_hold_minutes\nFROM \"Trade\"\nWHERE status = 'closed'\n AND \"isTestTrade\" = false\n AND \"entryTime\" >= NOW() - INTERVAL '30 days'\nGROUP BY \"exitReason\"\nORDER BY count DESC;"
},
"id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
"name": "Query Exit Reasons",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.4,
"position": [460, 300],
"credentials": {
"postgres": {
"id": "1",
"name": "Trading Bot Database"
}
}
},
{
"parameters": {
"operation": "executeQuery",
"query": "-- Stop loss effectiveness by distance\nSELECT \n CASE \n WHEN ABS((\"stopLossPrice\" - \"entryPrice\") / \"entryPrice\" * 100) < 0.5 THEN 'Very Tight (< 0.5%)'\n WHEN ABS((\"stopLossPrice\" - \"entryPrice\") / \"entryPrice\" * 100) < 1.0 THEN 'Tight (0.5-1%)'\n WHEN ABS((\"stopLossPrice\" - \"entryPrice\") / \"entryPrice\" * 100) < 1.5 THEN 'Normal (1-1.5%)'\n WHEN ABS((\"stopLossPrice\" - \"entryPrice\") / \"entryPrice\" * 100) < 2.0 THEN 'Wide (1.5-2%)'\n ELSE 'Very Wide (> 2%)'\n END as sl_distance,\n COUNT(*) as total_trades,\n COUNT(CASE WHEN \"exitReason\" LIKE '%stop%' THEN 1 END) as stopped_out,\n ROUND(COUNT(CASE WHEN \"exitReason\" LIKE '%stop%' THEN 1 END)::numeric / NULLIF(COUNT(*), 0) * 100, 2) as stop_hit_rate,\n ROUND(AVG(\"realizedPnL\")::numeric, 4) as avg_pnl\nFROM \"Trade\"\nWHERE status = 'closed'\n AND \"isTestTrade\" = false\n AND \"stopLossPrice\" IS NOT NULL\n AND \"entryPrice\" > 0\n AND \"entryTime\" >= NOW() - INTERVAL '30 days'\nGROUP BY \n CASE \n WHEN ABS((\"stopLossPrice\" - \"entryPrice\") / \"entryPrice\" * 100) < 0.5 THEN 'Very Tight (< 0.5%)'\n WHEN ABS((\"stopLossPrice\" - \"entryPrice\") / \"entryPrice\" * 100) < 1.0 THEN 'Tight (0.5-1%)'\n WHEN ABS((\"stopLossPrice\" - \"entryPrice\") / \"entryPrice\" * 100) < 1.5 THEN 'Normal (1-1.5%)'\n WHEN ABS((\"stopLossPrice\" - \"entryPrice\") / \"entryPrice\" * 100) < 2.0 THEN 'Wide (1.5-2%)'\n ELSE 'Very Wide (> 2%)'\n END\nORDER BY \n CASE \n WHEN sl_distance = 'Very Tight (< 0.5%)' THEN 1\n WHEN sl_distance = 'Tight (0.5-1%)' THEN 2\n WHEN sl_distance = 'Normal (1-1.5%)' THEN 3\n WHEN sl_distance = 'Wide (1.5-2%)' THEN 4\n ELSE 5\n END;"
},
"id": "3c4d5e6f-7a8b-9c0d-1e2f-3a4b5c6d7e8f",
"name": "Query Stop Distance Analysis",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.4,
"position": [460, 500],
"credentials": {
"postgres": {
"id": "1",
"name": "Trading Bot Database"
}
}
},
{
"parameters": {
"operation": "executeQuery",
"query": "-- Trades that got stopped out vs those that hit targets\nSELECT \n direction,\n symbol,\n COUNT(CASE WHEN \"exitReason\" LIKE '%stop%' THEN 1 END) as stopped_out,\n COUNT(CASE WHEN \"exitReason\" LIKE '%target%' OR \"exitReason\" LIKE '%TP%' THEN 1 END) as hit_targets,\n COUNT(CASE WHEN \"exitReason\" LIKE '%manual%' THEN 1 END) as manual_exits,\n ROUND(AVG(CASE WHEN \"exitReason\" LIKE '%stop%' THEN \"realizedPnL\" END)::numeric, 4) as avg_stop_loss,\n ROUND(AVG(CASE WHEN \"exitReason\" LIKE '%target%' OR \"exitReason\" LIKE '%TP%' THEN \"realizedPnL\" END)::numeric, 4) as avg_target_win\nFROM \"Trade\"\nWHERE status = 'closed'\n AND \"isTestTrade\" = false\n AND \"entryTime\" >= NOW() - INTERVAL '30 days'\nGROUP BY direction, symbol\nORDER BY stopped_out DESC;"
},
"id": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a",
"name": "Query Stop vs Target by Symbol",
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.4,
"position": [460, 700],
"credentials": {
"postgres": {
"id": "1",
"name": "Trading Bot Database"
}
}
},
{
"parameters": {
"jsCode": "const exitReasons = $('Query Exit Reasons').all().map(item => item.json);\nconst stopDistances = $('Query Stop Distance Analysis').all().map(item => item.json);\nconst symbolBreakdown = $('Query Stop vs Target by Symbol').all().map(item => item.json);\n\n// Calculate stop loss hit rate\nconst stopLossExits = exitReasons.filter(r => r.exitReason && r.exitReason.toLowerCase().includes('stop'));\nconst totalStopLosses = stopLossExits.reduce((sum, r) => sum + parseInt(r.count), 0);\nconst totalTrades = exitReasons.reduce((sum, r) => sum + parseInt(r.count), 0);\nconst stopHitRate = totalTrades > 0 ? (totalStopLosses / totalTrades * 100).toFixed(2) : 0;\n\n// Find optimal stop distance\nconst sortedByPnL = [...stopDistances].sort((a, b) => parseFloat(b.avg_pnl) - parseFloat(a.avg_pnl));\nconst optimalDistance = sortedByPnL[0];\nconst worstDistance = sortedByPnL[sortedByPnL.length - 1];\n\n// Calculate profit factor (avg win / avg loss)\nconst avgStopLoss = exitReasons\n .filter(r => r.exitReason && r.exitReason.toLowerCase().includes('stop'))\n .reduce((sum, r) => sum + parseFloat(r.avg_pnl) * parseInt(r.count), 0) / totalStopLosses;\n\nconst targetExits = exitReasons.filter(r => \n r.exitReason && (r.exitReason.toLowerCase().includes('target') || r.exitReason.toLowerCase().includes('tp'))\n);\nconst totalTargets = targetExits.reduce((sum, r) => sum + parseInt(r.count), 0);\nconst avgTarget = targetExits.reduce((sum, r) => sum + parseFloat(r.avg_pnl) * parseInt(r.count), 0) / totalTargets;\n\nconst insights = {\n summary: {\n stopHitRate: `${stopHitRate}%`,\n totalStopLosses,\n avgStopLoss: `$${avgStopLoss.toFixed(4)}`,\n avgTargetWin: `$${avgTarget.toFixed(4)}`,\n profitFactor: avgStopLoss !== 0 ? (Math.abs(avgTarget / avgStopLoss)).toFixed(2) : 'N/A'\n },\n optimalStopDistance: {\n distance: optimalDistance?.sl_distance || 'N/A',\n avgPnL: optimalDistance ? `$${optimalDistance.avg_pnl}` : 'N/A',\n stopHitRate: optimalDistance ? `${optimalDistance.stop_hit_rate}%` : 'N/A',\n recommendation: optimalDistance ? `Use ${optimalDistance.sl_distance} stop losses` : 'Need more data'\n },\n worstStopDistance: {\n distance: worstDistance?.sl_distance || 'N/A',\n avgPnL: worstDistance ? `$${worstDistance.avg_pnl}` : 'N/A',\n stopHitRate: worstDistance ? `${worstDistance.stop_hit_rate}%` : 'N/A'\n },\n recommendations: [\n stopHitRate > 50 ? '⚠️ High stop hit rate - consider wider stops or better entries' : '✅ Stop hit rate is acceptable',\n optimalDistance && worstDistance ? `💡 ${optimalDistance.sl_distance} performs ${((parseFloat(optimalDistance.avg_pnl) - parseFloat(worstDistance.avg_pnl)) / Math.abs(parseFloat(worstDistance.avg_pnl)) * 100).toFixed(1)}% better than ${worstDistance.sl_distance}` : '',\n Math.abs(avgStopLoss) > Math.abs(avgTarget) ? '⚠️ Average losses exceed average wins - adjust risk/reward' : '✅ Risk/reward ratio is positive'\n ].filter(Boolean),\n rawData: {\n exitReasons,\n stopDistances,\n symbolBreakdown\n }\n};\n\nreturn [{ json: insights }];"
},
"id": "5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0b",
"name": "Analyze Stop Effectiveness",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [680, 300]
}
],
"connections": {
"Manual Trigger": {
"main": [
[
{
"node": "Query Exit Reasons",
"type": "main",
"index": 0
},
{
"node": "Query Stop Distance Analysis",
"type": "main",
"index": 0
},
{
"node": "Query Stop vs Target by Symbol",
"type": "main",
"index": 0
}
]
]
},
"Query Exit Reasons": {
"main": [
[
{
"node": "Analyze Stop Effectiveness",
"type": "main",
"index": 0
}
]
]
},
"Query Stop Distance Analysis": {
"main": [
[
{
"node": "Analyze Stop Effectiveness",
"type": "main",
"index": 0
}
]
]
},
"Query Stop vs Target by Symbol": {
"main": [
[
{
"node": "Analyze Stop Effectiveness",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"tags": [],
"triggerCount": 0,
"updatedAt": "2025-10-27T00:00:00.000Z",
"versionId": "1"
}