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:
317
N8N_DATABASE_SETUP.md
Normal file
317
N8N_DATABASE_SETUP.md
Normal 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!
|
||||
@@ -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 ExecuteTradeRequest {
|
||||
@@ -209,7 +209,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ExecuteTr
|
||||
}
|
||||
|
||||
// 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')
|
||||
|
||||
@@ -45,6 +45,19 @@ export interface CreateTradeParams {
|
||||
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 {
|
||||
positionId: string
|
||||
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)
|
||||
*/
|
||||
|
||||
@@ -498,6 +498,15 @@ export async function closePosition(
|
||||
console.log(` Close price: $${oraclePrice.toFixed(4)}`)
|
||||
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 {
|
||||
success: true,
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -8,6 +8,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 { updateTradeExit, updateTradeState, getOpenTrades } from '../database/trades'
|
||||
|
||||
export interface ActiveTrade {
|
||||
id: string
|
||||
@@ -46,7 +47,7 @@ export interface ActiveTrade {
|
||||
|
||||
export interface ExitResult {
|
||||
success: boolean
|
||||
reason: 'TP1' | 'TP2' | 'SL' | 'emergency' | 'manual' | 'error'
|
||||
reason: 'TP1' | 'TP2' | 'SL' | 'SOFT_SL' | 'HARD_SL' | 'emergency' | 'manual' | 'error'
|
||||
closePrice?: number
|
||||
closedSize?: number
|
||||
realizedPnL?: number
|
||||
@@ -58,12 +59,74 @@ export class PositionManager {
|
||||
private activeTrades: Map<string, ActiveTrade> = new Map()
|
||||
private config: TradingConfig
|
||||
private isMonitoring: boolean = false
|
||||
private initialized: boolean = false
|
||||
|
||||
constructor(config?: Partial<TradingConfig>) {
|
||||
this.config = getMergedConfig(config)
|
||||
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
|
||||
*/
|
||||
@@ -72,6 +135,9 @@ export class PositionManager {
|
||||
|
||||
this.activeTrades.set(trade.id, trade)
|
||||
|
||||
// Save initial state to database
|
||||
await this.saveTradeState(trade)
|
||||
|
||||
console.log(`✅ Trade added. Active trades: ${this.activeTrades.size}`)
|
||||
|
||||
// Start monitoring if not already running
|
||||
@@ -238,17 +304,20 @@ export class PositionManager {
|
||||
console.log(`🎉 TP1 HIT: ${trade.symbol} at ${profitPercent.toFixed(2)}%`)
|
||||
await this.executeExit(trade, 50, 'TP1', currentPrice)
|
||||
|
||||
// Move SL to breakeven
|
||||
// Move SL to secure profit after TP1
|
||||
trade.tp1Hit = true
|
||||
trade.currentSize = trade.positionSize * 0.5
|
||||
trade.stopLossPrice = this.calculatePrice(
|
||||
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.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
|
||||
}
|
||||
|
||||
@@ -268,6 +337,9 @@ export class PositionManager {
|
||||
trade.slMovedToProfit = true
|
||||
|
||||
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%)
|
||||
@@ -305,8 +377,29 @@ export class PositionManager {
|
||||
if (percentToClose >= 100) {
|
||||
// Full close - remove from monitoring
|
||||
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}`)
|
||||
} else {
|
||||
// Partial close (TP1)
|
||||
@@ -316,7 +409,6 @@ export class PositionManager {
|
||||
console.log(`✅ 50% closed | Realized: $${result.realizedPnL?.toFixed(2)} | Remaining: ${trade.currentSize}`)
|
||||
}
|
||||
|
||||
// TODO: Save to database
|
||||
// TODO: Send notification
|
||||
|
||||
} catch (error) {
|
||||
@@ -404,6 +496,29 @@ export class PositionManager {
|
||||
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
|
||||
*/
|
||||
@@ -426,10 +541,26 @@ export class PositionManager {
|
||||
|
||||
// Singleton instance
|
||||
let positionManagerInstance: PositionManager | null = null
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
export function getPositionManager(): PositionManager {
|
||||
if (!positionManagerInstance) {
|
||||
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
|
||||
}
|
||||
|
||||
export async function getInitializedPositionManager(): Promise<PositionManager> {
|
||||
const manager = getPositionManager()
|
||||
if (initPromise) {
|
||||
await initPromise
|
||||
}
|
||||
return manager
|
||||
}
|
||||
|
||||
171
n8n-daily-report.json
Normal file
171
n8n-daily-report.json
Normal 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"
|
||||
}
|
||||
73
n8n-database-analytics.json
Normal file
73
n8n-database-analytics.json
Normal 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
139
n8n-pattern-analysis.json
Normal 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
139
n8n-stop-loss-analysis.json
Normal 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"
|
||||
}
|
||||
Reference in New Issue
Block a user