|
@@ -1,4 +1,4 @@
|
|
|
-import { formatUnits } from 'viem'
|
|
|
|
|
|
|
+import { formatUnits, parseUnits } from 'viem'
|
|
|
import { config, MON_DECIMALS, USDC_DECIMALS } from '../config'
|
|
import { config, MON_DECIMALS, USDC_DECIMALS } from '../config'
|
|
|
import {
|
|
import {
|
|
|
getActiveId,
|
|
getActiveId,
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
openPosition,
|
|
openPosition,
|
|
|
closePosition,
|
|
closePosition,
|
|
|
rebalanceSwap,
|
|
rebalanceSwap,
|
|
|
- calculatePositionAmounts,
|
|
|
|
|
} from '../chain'
|
|
} from '../chain'
|
|
|
import {
|
|
import {
|
|
|
getCurrentPosition,
|
|
getCurrentPosition,
|
|
@@ -21,18 +20,21 @@ import {
|
|
|
setEngineState,
|
|
setEngineState,
|
|
|
getEngineState,
|
|
getEngineState,
|
|
|
} from '../db/queries'
|
|
} from '../db/queries'
|
|
|
-import { sendNotification, notifyRebalance, notifyError } from '../notifications'
|
|
|
|
|
|
|
+import { notifyRebalance, notifyError, sendNotification } from '../notifications'
|
|
|
import type { EngineStatus, EngineState, RebalanceResult } from './types'
|
|
import type { EngineStatus, EngineState, RebalanceResult } from './types'
|
|
|
|
|
|
|
|
|
|
+const GAS_RESERVE_MON = 0.5
|
|
|
|
|
+const ERROR_COOLDOWN_MS = 30_000 // 30s cooldown after error
|
|
|
|
|
+
|
|
|
export class RebalancerEngine {
|
|
export class RebalancerEngine {
|
|
|
private static instance: RebalancerEngine | null = null
|
|
private static instance: RebalancerEngine | null = null
|
|
|
|
|
|
|
|
- private pollTimer: ReturnType<typeof setInterval> | null = null
|
|
|
|
|
private status: EngineStatus = 'idle'
|
|
private status: EngineStatus = 'idle'
|
|
|
private cooldownUntil = 0
|
|
private cooldownUntil = 0
|
|
|
private errors: string[] = []
|
|
private errors: string[] = []
|
|
|
private startedAt = 0
|
|
private startedAt = 0
|
|
|
- private polling = false
|
|
|
|
|
|
|
+ private running = false
|
|
|
|
|
+ private stopRequested = false
|
|
|
|
|
|
|
|
private constructor() {}
|
|
private constructor() {}
|
|
|
|
|
|
|
@@ -44,28 +46,25 @@ export class RebalancerEngine {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
start(): void {
|
|
start(): void {
|
|
|
- if (this.status === 'running') return
|
|
|
|
|
|
|
+ if (this.running) return
|
|
|
this.status = 'running'
|
|
this.status = 'running'
|
|
|
this.startedAt = Date.now()
|
|
this.startedAt = Date.now()
|
|
|
|
|
+ this.stopRequested = false
|
|
|
setEngineState('status', 'running')
|
|
setEngineState('status', 'running')
|
|
|
console.log('[engine] Started')
|
|
console.log('[engine] Started')
|
|
|
-
|
|
|
|
|
- this.pollTimer = setInterval(() => this.pollCycle(), config.strategy.pollIntervalMs)
|
|
|
|
|
|
|
+ this.runLoop()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
pause(): void {
|
|
pause(): void {
|
|
|
- if (this.pollTimer) clearInterval(this.pollTimer)
|
|
|
|
|
- this.pollTimer = null
|
|
|
|
|
|
|
+ this.stopRequested = true
|
|
|
this.status = 'paused'
|
|
this.status = 'paused'
|
|
|
setEngineState('status', 'paused')
|
|
setEngineState('status', 'paused')
|
|
|
console.log('[engine] Paused')
|
|
console.log('[engine] Paused')
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
stop(): void {
|
|
stop(): void {
|
|
|
- if (this.pollTimer) clearInterval(this.pollTimer)
|
|
|
|
|
- this.pollTimer = null
|
|
|
|
|
|
|
+ this.stopRequested = true
|
|
|
this.status = 'idle'
|
|
this.status = 'idle'
|
|
|
- this.polling = false
|
|
|
|
|
setEngineState('status', 'idle')
|
|
setEngineState('status', 'idle')
|
|
|
console.log('[engine] Stopped')
|
|
console.log('[engine] Stopped')
|
|
|
}
|
|
}
|
|
@@ -109,7 +108,7 @@ export class RebalancerEngine {
|
|
|
currentActiveId = ap.activeId
|
|
currentActiveId = ap.activeId
|
|
|
currentPrice = ap.price
|
|
currentPrice = ap.price
|
|
|
} catch {
|
|
} catch {
|
|
|
- // chain read failed, leave as null
|
|
|
|
|
|
|
+ // chain read failed
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const position = getCurrentPosition()
|
|
const position = getCurrentPosition()
|
|
@@ -144,65 +143,120 @@ export class RebalancerEngine {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private async pollCycle(): Promise<void> {
|
|
|
|
|
- if (this.status !== 'running' || this.polling) return
|
|
|
|
|
- this.polling = true
|
|
|
|
|
|
|
+ // ── Main loop: sequential, waits for each cycle to finish ──
|
|
|
|
|
+
|
|
|
|
|
+ private async runLoop(): Promise<void> {
|
|
|
|
|
+ if (this.running) return
|
|
|
|
|
+ this.running = true
|
|
|
|
|
+
|
|
|
|
|
+ while (!this.stopRequested) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await this.pollCycle()
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ const msg = err instanceof Error ? err.message : String(err)
|
|
|
|
|
+ this.addError(msg)
|
|
|
|
|
+ console.error('[engine] Poll error:', msg)
|
|
|
|
|
+ // Cooldown after unexpected error
|
|
|
|
|
+ this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + ERROR_COOLDOWN_MS)
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- try {
|
|
|
|
|
- const currentActiveId = await getActiveId()
|
|
|
|
|
- const position = getCurrentPosition()
|
|
|
|
|
|
|
+ // Wait before next cycle
|
|
|
|
|
+ await this.sleep(config.strategy.pollIntervalMs)
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- if (!position) {
|
|
|
|
|
- console.log('[engine] No position, opening new one at bin', currentActiveId)
|
|
|
|
|
- await this.openNewPosition(currentActiveId)
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ this.running = false
|
|
|
|
|
+ console.log('[engine] Loop exited')
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // Check if active bin is within position range
|
|
|
|
|
- if (currentActiveId >= position.minBin && currentActiveId <= position.maxBin) {
|
|
|
|
|
- return // In range, no action needed
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ private async pollCycle(): Promise<void> {
|
|
|
|
|
+ // Respect cooldown
|
|
|
|
|
+ const now = Date.now()
|
|
|
|
|
+ if (now < this.cooldownUntil) return
|
|
|
|
|
|
|
|
- // Out of range — check cooldown
|
|
|
|
|
- if (Date.now() < this.cooldownUntil) {
|
|
|
|
|
- console.log('[engine] Cooldown active, skipping rebalance')
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const currentActiveId = await getActiveId()
|
|
|
|
|
+ const position = getCurrentPosition()
|
|
|
|
|
|
|
|
- // Check daily limit
|
|
|
|
|
- const dailyCount = getDailyRebalanceCount()
|
|
|
|
|
- if (dailyCount >= config.strategy.maxDailyRebalances) {
|
|
|
|
|
- console.log('[engine] Daily rebalance limit reached:', dailyCount)
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (!position) {
|
|
|
|
|
+ console.log('[engine] No position found, opening at bin', currentActiveId)
|
|
|
|
|
+ await this.openNewPosition(currentActiveId)
|
|
|
|
|
+ // Cooldown after opening to let chain settle
|
|
|
|
|
+ this.cooldownUntil = Date.now() + config.strategy.rebalanceCooldownMs
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- console.log(
|
|
|
|
|
- `[engine] Out of range! Active: ${currentActiveId}, Range: [${position.minBin}, ${position.maxBin}]`,
|
|
|
|
|
- )
|
|
|
|
|
- await this.executeRebalance(currentActiveId, position)
|
|
|
|
|
- } catch (err) {
|
|
|
|
|
- const msg = err instanceof Error ? err.message : String(err)
|
|
|
|
|
- this.addError(msg)
|
|
|
|
|
- console.error('[engine] Poll cycle error:', msg)
|
|
|
|
|
- } finally {
|
|
|
|
|
- this.polling = false
|
|
|
|
|
|
|
+ // In range → nothing to do
|
|
|
|
|
+ if (currentActiveId >= position.minBin && currentActiveId <= position.maxBin) {
|
|
|
|
|
+ return
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // Out of range → check daily limit
|
|
|
|
|
+ const dailyCount = getDailyRebalanceCount()
|
|
|
|
|
+ if (dailyCount >= config.strategy.maxDailyRebalances) {
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ console.log(
|
|
|
|
|
+ `[engine] Out of range: active=${currentActiveId}, position=[${position.minBin}, ${position.maxBin}]`,
|
|
|
|
|
+ )
|
|
|
|
|
+ await this.executeRebalance(currentActiveId, position)
|
|
|
|
|
+ this.cooldownUntil = Date.now() + config.strategy.rebalanceCooldownMs
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ── Open position ──
|
|
|
|
|
+
|
|
|
private async openNewPosition(activeId: number): Promise<void> {
|
|
private async openNewPosition(activeId: number): Promise<void> {
|
|
|
const price = getPriceFromBinId(activeId)
|
|
const price = getPriceFromBinId(activeId)
|
|
|
const balances = await getWalletBalances()
|
|
const balances = await getWalletBalances()
|
|
|
const monBalance = Number(formatUnits(balances.mon, MON_DECIMALS))
|
|
const monBalance = Number(formatUnits(balances.mon, MON_DECIMALS))
|
|
|
const usdcBalance = Number(formatUnits(balances.usdc, USDC_DECIMALS))
|
|
const usdcBalance = Number(formatUnits(balances.usdc, USDC_DECIMALS))
|
|
|
- const totalUsd = monBalance * price + usdcBalance
|
|
|
|
|
|
|
|
|
|
- const positionSizeUsd = Math.min(config.strategy.positionSizeUSD, totalUsd * 0.95)
|
|
|
|
|
|
|
+ const availableMon = Math.max(0, monBalance - GAS_RESERVE_MON)
|
|
|
|
|
+ const availableMonUsd = availableMon * price
|
|
|
|
|
+ const availableUsdcUsd = usdcBalance
|
|
|
|
|
+ const totalAvailableUsd = availableMonUsd + availableUsdcUsd
|
|
|
|
|
+
|
|
|
|
|
+ const positionSizeUsd = Math.min(config.strategy.positionSizeUSD, totalAvailableUsd * 0.95)
|
|
|
|
|
+
|
|
|
if (positionSizeUsd < 1) {
|
|
if (positionSizeUsd < 1) {
|
|
|
- this.addError('Insufficient balance to open position')
|
|
|
|
|
|
|
+ this.addError(
|
|
|
|
|
+ `Insufficient balance: ${availableMonUsd.toFixed(2)}U MON, ${availableUsdcUsd.toFixed(2)}U USDC`,
|
|
|
|
|
+ )
|
|
|
|
|
+ // Long cooldown to avoid spamming
|
|
|
|
|
+ this.cooldownUntil = Date.now() + 60_000
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const { amountX, amountY } = calculatePositionAmounts(positionSizeUsd, price)
|
|
|
|
|
|
|
+ // Allocate each side: ideal 50/50, but allow single-sided
|
|
|
|
|
+ const idealHalf = positionSizeUsd / 2
|
|
|
|
|
+ let monUsdToUse = Math.min(idealHalf, availableMonUsd)
|
|
|
|
|
+ let usdcUsdToUse = Math.min(idealHalf, availableUsdcUsd)
|
|
|
|
|
+
|
|
|
|
|
+ // If one side can't fill half, give remainder to the other
|
|
|
|
|
+ if (monUsdToUse < idealHalf) {
|
|
|
|
|
+ usdcUsdToUse = Math.min(usdcUsdToUse + (idealHalf - monUsdToUse), availableUsdcUsd)
|
|
|
|
|
+ }
|
|
|
|
|
+ if (usdcUsdToUse < idealHalf) {
|
|
|
|
|
+ monUsdToUse = Math.min(monUsdToUse + (idealHalf - usdcUsdToUse), availableMonUsd)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const amountX =
|
|
|
|
|
+ monUsdToUse > 0
|
|
|
|
|
+ ? parseUnits((monUsdToUse / price).toFixed(MON_DECIMALS), MON_DECIMALS)
|
|
|
|
|
+ : 0n
|
|
|
|
|
+ const amountY =
|
|
|
|
|
+ usdcUsdToUse > 0
|
|
|
|
|
+ ? parseUnits(usdcUsdToUse.toFixed(USDC_DECIMALS), USDC_DECIMALS)
|
|
|
|
|
+ : 0n
|
|
|
|
|
+
|
|
|
|
|
+ if (amountX === 0n && amountY === 0n) {
|
|
|
|
|
+ this.addError('Insufficient balance after gas reserve')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ console.log(
|
|
|
|
|
+ `[engine] Opening: ${monUsdToUse.toFixed(2)}U MON + ${usdcUsdToUse.toFixed(2)}U USDC = $${(monUsdToUse + usdcUsdToUse).toFixed(2)}`,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
const result = await openPosition(activeId, config.strategy.numBins, amountX, amountY)
|
|
const result = await openPosition(activeId, config.strategy.numBins, amountX, amountY)
|
|
|
|
|
|
|
|
upsertPosition({
|
|
upsertPosition({
|
|
@@ -216,10 +270,12 @@ export class RebalancerEngine {
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
console.log(
|
|
console.log(
|
|
|
- `[engine] Position opened at bin ${activeId}, range [${result.minBin}, ${result.maxBin}]`,
|
|
|
|
|
|
|
+ `[engine] Position opened: bin=${activeId}, range=[${result.minBin}, ${result.maxBin}], tx=${result.txHash}`,
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ── Rebalance ──
|
|
|
|
|
+
|
|
|
private async executeRebalance(
|
|
private async executeRebalance(
|
|
|
newActiveId: number,
|
|
newActiveId: number,
|
|
|
position: NonNullable<ReturnType<typeof getCurrentPosition>>,
|
|
position: NonNullable<ReturnType<typeof getCurrentPosition>>,
|
|
@@ -234,26 +290,34 @@ export class RebalancerEngine {
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
// Step 1: Remove liquidity
|
|
// Step 1: Remove liquidity
|
|
|
- console.log('[engine] Removing liquidity...')
|
|
|
|
|
|
|
+ console.log('[engine] Step 1/3: Removing liquidity...')
|
|
|
const removeResult = await closePosition(position.binIds)
|
|
const removeResult = await closePosition(position.binIds)
|
|
|
if (removeResult) {
|
|
if (removeResult) {
|
|
|
result.removeTxHash = removeResult.txHash
|
|
result.removeTxHash = removeResult.txHash
|
|
|
result.amountXRemoved = removeResult.amountX.toString()
|
|
result.amountXRemoved = removeResult.amountX.toString()
|
|
|
result.amountYRemoved = removeResult.amountY.toString()
|
|
result.amountYRemoved = removeResult.amountY.toString()
|
|
|
|
|
+ console.log(`[engine] Removed: tx=${removeResult.txHash}`)
|
|
|
}
|
|
}
|
|
|
deleteCurrentPosition()
|
|
deleteCurrentPosition()
|
|
|
|
|
|
|
|
- // Step 2: Swap to rebalance if needed
|
|
|
|
|
|
|
+ // Step 2: Swap to rebalance if needed (non-blocking: if swap fails, continue with available tokens)
|
|
|
|
|
+ console.log('[engine] Step 2/3: Checking swap...')
|
|
|
const price = getPriceFromBinId(newActiveId)
|
|
const price = getPriceFromBinId(newActiveId)
|
|
|
- console.log('[engine] Checking swap rebalance...')
|
|
|
|
|
- const swapResult = await rebalanceSwap(price)
|
|
|
|
|
- if (swapResult.swapTxHash) {
|
|
|
|
|
- result.swapTxHash = swapResult.swapTxHash
|
|
|
|
|
- result.swapDirection = swapResult.direction
|
|
|
|
|
|
|
+ try {
|
|
|
|
|
+ const swapResult = await rebalanceSwap(price)
|
|
|
|
|
+ if (swapResult.swapTxHash) {
|
|
|
|
|
+ result.swapTxHash = swapResult.swapTxHash
|
|
|
|
|
+ result.swapDirection = swapResult.direction
|
|
|
|
|
+ console.log(`[engine] Swapped: ${swapResult.direction}, tx=${swapResult.swapTxHash}`)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.log('[engine] No swap needed')
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (swapErr) {
|
|
|
|
|
+ console.warn('[engine] Swap failed, continuing with available tokens:', swapErr instanceof Error ? swapErr.message : swapErr)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Step 3: Open new position
|
|
// Step 3: Open new position
|
|
|
- console.log('[engine] Opening new position at bin', newActiveId)
|
|
|
|
|
|
|
+ console.log('[engine] Step 3/3: Opening new position at bin', newActiveId)
|
|
|
await this.openNewPosition(newActiveId)
|
|
await this.openNewPosition(newActiveId)
|
|
|
|
|
|
|
|
const pos = getCurrentPosition()
|
|
const pos = getCurrentPosition()
|
|
@@ -262,7 +326,6 @@ export class RebalancerEngine {
|
|
|
result.success = true
|
|
result.success = true
|
|
|
result.durationMs = Date.now() - startTime
|
|
result.durationMs = Date.now() - startTime
|
|
|
|
|
|
|
|
- // Log to DB
|
|
|
|
|
logRebalance({
|
|
logRebalance({
|
|
|
prevActiveId: result.prevActiveId!,
|
|
prevActiveId: result.prevActiveId!,
|
|
|
newActiveId: result.newActiveId!,
|
|
newActiveId: result.newActiveId!,
|
|
@@ -280,12 +343,12 @@ export class RebalancerEngine {
|
|
|
durationMs: result.durationMs,
|
|
durationMs: result.durationMs,
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
- // Update state
|
|
|
|
|
- this.cooldownUntil = Date.now() + config.strategy.rebalanceCooldownMs
|
|
|
|
|
incrementDailyRebalanceCount()
|
|
incrementDailyRebalanceCount()
|
|
|
setEngineState('last_rebalance_at', new Date().toISOString())
|
|
setEngineState('last_rebalance_at', new Date().toISOString())
|
|
|
|
|
|
|
|
- console.log(`[engine] Rebalance complete in ${result.durationMs}ms`)
|
|
|
|
|
|
|
+ console.log(
|
|
|
|
|
+ `[engine] Rebalance complete: [${result.prevMinBin},${result.prevMaxBin}] → [${result.newMinBin},${result.newMaxBin}] in ${result.durationMs}ms`,
|
|
|
|
|
+ )
|
|
|
await notifyRebalance(result as RebalanceResult)
|
|
await notifyRebalance(result as RebalanceResult)
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
const msg = err instanceof Error ? err.message : String(err)
|
|
const msg = err instanceof Error ? err.message : String(err)
|
|
@@ -306,12 +369,19 @@ export class RebalancerEngine {
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
this.addError(`Rebalance failed: ${msg}`)
|
|
this.addError(`Rebalance failed: ${msg}`)
|
|
|
|
|
+ console.error('[engine] Rebalance failed:', msg)
|
|
|
await notifyError(`Rebalance failed: ${msg}`)
|
|
await notifyError(`Rebalance failed: ${msg}`)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ── Helpers ──
|
|
|
|
|
+
|
|
|
private addError(msg: string): void {
|
|
private addError(msg: string): void {
|
|
|
- this.errors.push(`${new Date().toISOString()} ${msg}`)
|
|
|
|
|
|
|
+ this.errors.push(`${new Date().toISOString().slice(11, 19)} ${msg}`)
|
|
|
if (this.errors.length > 20) this.errors.shift()
|
|
if (this.errors.length > 20) this.errors.shift()
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ private sleep(ms: number): Promise<void> {
|
|
|
|
|
+ return new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|