|
@@ -57,11 +57,31 @@ export interface MonitoringConfig {
|
|
tickSize?: number;
|
|
tickSize?: number;
|
|
};
|
|
};
|
|
tradingSymbol: string;
|
|
tradingSymbol: string;
|
|
|
|
+ utilizationThresholds?: {
|
|
|
|
+ build: number;
|
|
|
|
+ reduce: number;
|
|
|
|
+ };
|
|
|
|
+ minOrderValue?: number;
|
|
|
|
+ effectiveLeverage?: number;
|
|
|
|
+ emergencyMitigation?: {
|
|
|
|
+ enabled?: boolean;
|
|
|
|
+ reduceFraction?: number;
|
|
|
|
+ minNotional?: number;
|
|
|
|
+ cooldownMs?: number;
|
|
|
|
+ useMarketOrder?: boolean;
|
|
|
|
+ netExposureThreshold?: number;
|
|
|
|
+ };
|
|
}
|
|
}
|
|
|
|
|
|
export class MonitoringManager {
|
|
export class MonitoringManager {
|
|
private logger: Logger;
|
|
private logger: Logger;
|
|
private config: MonitoringConfig;
|
|
private config: MonitoringConfig;
|
|
|
|
+ private utilizationThresholds: { build: number; reduce: number } = { build: 0.6, reduce: 0.8 };
|
|
|
|
+ private minOrderValue: number = 10;
|
|
|
|
+ private effectiveLeverage: number = 1;
|
|
|
|
+ private emergencyMitigationConfig: MonitoringConfig['emergencyMitigation'] | null = null;
|
|
|
|
+ private lastEmergencyMitigation = 0;
|
|
|
|
+ private emergencyReductionActive = false;
|
|
|
|
|
|
// Dependencies
|
|
// Dependencies
|
|
private dataAggregator: DataAggregator;
|
|
private dataAggregator: DataAggregator;
|
|
@@ -82,6 +102,10 @@ export class MonitoringManager {
|
|
const stopLossConfig = raw['stopLoss'] ?? {};
|
|
const stopLossConfig = raw['stopLoss'] ?? {};
|
|
const takeProfitConfig = raw['takeProfit'] ?? {};
|
|
const takeProfitConfig = raw['takeProfit'] ?? {};
|
|
const maxExposureConfig = raw['maxExposure'] ?? {};
|
|
const maxExposureConfig = raw['maxExposure'] ?? {};
|
|
|
|
+ const leverageCandidate = Number(raw['leverage'] ?? raw['leverageMultiplier']);
|
|
|
|
+ const leverageMultiplier = Number.isFinite(leverageCandidate) && leverageCandidate > 0
|
|
|
|
+ ? leverageCandidate
|
|
|
|
+ : this.effectiveLeverage;
|
|
|
|
|
|
return {
|
|
return {
|
|
enabled: Boolean(raw.enabled),
|
|
enabled: Boolean(raw.enabled),
|
|
@@ -100,7 +124,7 @@ export class MonitoringManager {
|
|
percent: Number(maxExposureConfig['percent'] ?? 0)
|
|
percent: Number(maxExposureConfig['percent'] ?? 0)
|
|
},
|
|
},
|
|
monitoringInterval: Number(raw['monitoringInterval'] ?? 5000),
|
|
monitoringInterval: Number(raw['monitoringInterval'] ?? 5000),
|
|
- leverageMultiplier: Number(raw['leverage'] ?? raw['leverageMultiplier'] ?? 1)
|
|
|
|
|
|
+ leverageMultiplier
|
|
};
|
|
};
|
|
}
|
|
}
|
|
|
|
|
|
@@ -120,6 +144,73 @@ export class MonitoringManager {
|
|
return 0;
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
+ private getUtilizationContext(): {
|
|
|
|
+ regime: 'build' | 'balance' | 'reduce';
|
|
|
|
+ metrics: ReturnType<AccountManager['getUtilizationMetrics']>;
|
|
|
|
+ } {
|
|
|
|
+ const metrics = this.accountManager.getUtilizationMetrics(this.effectiveLeverage);
|
|
|
|
+ const build = this.utilizationThresholds.build;
|
|
|
|
+ const reduce = this.utilizationThresholds.reduce;
|
|
|
|
+
|
|
|
|
+ let regime: 'build' | 'balance' | 'reduce';
|
|
|
|
+ if (metrics.maxUtilization >= reduce) {
|
|
|
|
+ regime = 'reduce';
|
|
|
|
+ } else if (metrics.maxUtilization >= build) {
|
|
|
|
+ regime = 'balance';
|
|
|
|
+ } else {
|
|
|
|
+ regime = 'build';
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return { regime, metrics };
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private clampExposureAmount(
|
|
|
|
+ amount: number,
|
|
|
|
+ currentPrice: number,
|
|
|
|
+ metrics: ReturnType<AccountManager['getUtilizationMetrics']>,
|
|
|
|
+ regime: 'build' | 'balance' | 'reduce'
|
|
|
|
+ ): number {
|
|
|
|
+ if (currentPrice <= 0) {
|
|
|
|
+ return amount;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const primaryEffective = metrics.baseEffectiveAvailable > 0
|
|
|
|
+ ? metrics.baseEffectiveAvailable
|
|
|
|
+ : metrics.baseEffectiveEquity;
|
|
|
|
+ const fallbackEffective = metrics.effectiveAvailable > 0
|
|
|
|
+ ? metrics.effectiveAvailable
|
|
|
|
+ : metrics.effectiveEquity;
|
|
|
|
+ const effectiveCapacity = primaryEffective > 0 ? primaryEffective : fallbackEffective;
|
|
|
|
+
|
|
|
|
+ if (effectiveCapacity <= 0) {
|
|
|
|
+ return amount;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ let ratio = regime === 'balance' ? 0.25 : 0.35;
|
|
|
|
+ if (regime === 'reduce') {
|
|
|
|
+ ratio = 0.15;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const maxNotional = Math.max(effectiveCapacity * ratio, 0);
|
|
|
|
+ if (maxNotional <= 0) {
|
|
|
|
+ return amount;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const lotSize = 0.00001;
|
|
|
|
+ const maxAmount = Math.floor((maxNotional / currentPrice) / lotSize) * lotSize;
|
|
|
|
+ if (maxAmount <= 0) {
|
|
|
|
+ return amount;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const minNotional = this.minOrderValue;
|
|
|
|
+ const minAmount = Math.ceil((minNotional / currentPrice) / lotSize) * lotSize;
|
|
|
|
+ if (maxAmount < minAmount) {
|
|
|
|
+ return 0;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ return Math.min(Math.max(amount, minAmount), maxAmount);
|
|
|
|
+ }
|
|
|
|
+
|
|
constructor(
|
|
constructor(
|
|
config: MonitoringConfig,
|
|
config: MonitoringConfig,
|
|
dependencies: {
|
|
dependencies: {
|
|
@@ -136,6 +227,28 @@ export class MonitoringManager {
|
|
this.signalExecutor = dependencies.signalExecutor;
|
|
this.signalExecutor = dependencies.signalExecutor;
|
|
this.tradingCycleManager = dependencies.tradingCycleManager || null;
|
|
this.tradingCycleManager = dependencies.tradingCycleManager || null;
|
|
this.tradingSymbol = config.tradingSymbol;
|
|
this.tradingSymbol = config.tradingSymbol;
|
|
|
|
+ if (config.utilizationThresholds) {
|
|
|
|
+ this.utilizationThresholds = config.utilizationThresholds;
|
|
|
|
+ }
|
|
|
|
+ if (typeof config.minOrderValue === 'number' && config.minOrderValue > 0) {
|
|
|
|
+ this.minOrderValue = config.minOrderValue;
|
|
|
|
+ }
|
|
|
|
+ if (typeof config.effectiveLeverage === 'number' && config.effectiveLeverage > 0) {
|
|
|
|
+ this.effectiveLeverage = config.effectiveLeverage;
|
|
|
|
+ }
|
|
|
|
+ if (config.emergencyMitigation) {
|
|
|
|
+ this.emergencyMitigationConfig = {
|
|
|
|
+ enabled: config.emergencyMitigation.enabled ?? true,
|
|
|
|
+ reduceFraction: config.emergencyMitigation.reduceFraction ?? 0.5,
|
|
|
|
+ minNotional: config.emergencyMitigation.minNotional ?? this.minOrderValue,
|
|
|
|
+ cooldownMs: config.emergencyMitigation.cooldownMs ?? 5000,
|
|
|
|
+ useMarketOrder: config.emergencyMitigation.useMarketOrder ?? true
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ if (config.emergencyMitigation.netExposureThreshold !== undefined) {
|
|
|
|
+ this.emergencyMitigationConfig.netExposureThreshold = config.emergencyMitigation.netExposureThreshold;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -702,7 +815,7 @@ export class MonitoringManager {
|
|
if (!priceData) continue;
|
|
if (!priceData) continue;
|
|
|
|
|
|
const markPrice = this.extractPrice(priceData);
|
|
const markPrice = this.extractPrice(priceData);
|
|
- const netPosition = typeof pos.amount === 'number' ? pos.amount : pos.size || 0;
|
|
|
|
|
|
+ const netPosition = this.normalizePositionSize(pos);
|
|
const positionExposure: PositionExposure = {
|
|
const positionExposure: PositionExposure = {
|
|
symbol: pos.symbol,
|
|
symbol: pos.symbol,
|
|
netPosition,
|
|
netPosition,
|
|
@@ -754,6 +867,69 @@ export class MonitoringManager {
|
|
await this.rebalanceExposurePositions(status.totalExposure, status.reason ?? undefined);
|
|
await this.rebalanceExposurePositions(status.totalExposure, status.reason ?? undefined);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
+ public async triggerEmergencyMitigation(reason?: string): Promise<void> {
|
|
|
|
+ if (!this.emergencyMitigationConfig?.enabled) {
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const { netSize: currentNetExposure, currentPrice } = this.computeSymbolNetExposure(this.tradingSymbol);
|
|
|
|
+ const thresholdSize = this.resolveEmergencyNetThreshold(currentPrice);
|
|
|
|
+ const absNetExposure = Math.abs(currentNetExposure);
|
|
|
|
+
|
|
|
|
+ if (absNetExposure <= thresholdSize) {
|
|
|
|
+ if (this.emergencyReductionActive) {
|
|
|
|
+ this.logger.info('Emergency mitigation satisfied below threshold', {
|
|
|
|
+ netExposure: absNetExposure,
|
|
|
|
+ threshold: thresholdSize
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+ this.clearEmergencyMitigation();
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const cooldown = this.emergencyMitigationConfig.cooldownMs ?? 5000;
|
|
|
|
+ const now = Date.now();
|
|
|
|
+ if (now - this.lastEmergencyMitigation < cooldown) {
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ this.emergencyReductionActive = true;
|
|
|
|
+ this.lastEmergencyMitigation = now;
|
|
|
|
+
|
|
|
|
+ const reduceFraction = this.emergencyMitigationConfig.reduceFraction ?? 0.5;
|
|
|
|
+ const minNotional = this.emergencyMitigationConfig.minNotional ?? this.minOrderValue;
|
|
|
|
+ const allowMarketOrders = this.emergencyMitigationConfig.useMarketOrder ?? false;
|
|
|
|
+
|
|
|
|
+ this.logger.warn('🚨 Emergency mitigation activated', {
|
|
|
|
+ reason: reason ?? 'phase_emergency',
|
|
|
|
+ reduceFraction,
|
|
|
|
+ minNotional,
|
|
|
|
+ cooldown
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ await this.rebalanceExposurePositions(0, reason ?? 'phase_emergency', {
|
|
|
|
+ forceReduce: true,
|
|
|
|
+ reduceFraction,
|
|
|
|
+ minNotional,
|
|
|
|
+ allowMarketOrders
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ const { netSize: postNetExposure } = this.computeSymbolNetExposure(this.tradingSymbol);
|
|
|
|
+ const postAbs = Math.abs(postNetExposure);
|
|
|
|
+ if (postAbs <= thresholdSize) {
|
|
|
|
+ this.logger.info('Emergency mitigation completed', {
|
|
|
|
+ netExposure: postAbs,
|
|
|
|
+ threshold: thresholdSize
|
|
|
|
+ });
|
|
|
|
+ this.clearEmergencyMitigation();
|
|
|
|
+ } else {
|
|
|
|
+ this.logger.warn('Emergency mitigation incomplete', {
|
|
|
|
+ netExposure: postAbs,
|
|
|
|
+ threshold: thresholdSize
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
/**
|
|
/**
|
|
* Close all positions for exposure control
|
|
* Close all positions for exposure control
|
|
*/
|
|
*/
|
|
@@ -773,7 +949,7 @@ export class MonitoringManager {
|
|
}
|
|
}
|
|
const priceData: PriceTick | undefined = aggregatedData.market.prices.get(symbol);
|
|
const priceData: PriceTick | undefined = aggregatedData.market.prices.get(symbol);
|
|
const currentPrice = this.extractPrice(priceData);
|
|
const currentPrice = this.extractPrice(priceData);
|
|
- const positionSize = typeof pos.amount === 'number' ? pos.amount : 0;
|
|
|
|
|
|
+ const positionSize = this.normalizePositionSize(pos);
|
|
|
|
|
|
const closePrice = positionSize > 0
|
|
const closePrice = positionSize > 0
|
|
? currentPrice * 0.995 // Sell lower
|
|
? currentPrice * 0.995 // Sell lower
|
|
@@ -800,12 +976,41 @@ export class MonitoringManager {
|
|
/**
|
|
/**
|
|
* Reduce positions for exposure control
|
|
* Reduce positions for exposure control
|
|
*/
|
|
*/
|
|
- private async rebalanceExposurePositions(_: number, reason?: string): Promise<void> {
|
|
|
|
|
|
+ private async rebalanceExposurePositions(
|
|
|
|
+ _: number,
|
|
|
|
+ reason?: string,
|
|
|
|
+ options?: {
|
|
|
|
+ forceReduce?: boolean;
|
|
|
|
+ reduceFraction?: number;
|
|
|
|
+ minNotional?: number;
|
|
|
|
+ allowMarketOrders?: boolean;
|
|
|
|
+ }
|
|
|
|
+ ): Promise<void> {
|
|
|
|
+ if (this.emergencyReductionActive && !(options?.forceReduce)) {
|
|
|
|
+ this.logger.debug('Emergency mitigation active, skipping non-forced exposure rebalance');
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
const reasonText = reason ?? 'auto_exposure_rebalance';
|
|
const reasonText = reason ?? 'auto_exposure_rebalance';
|
|
const aggregatedData = this.dataAggregator.getAggregatedData();
|
|
const aggregatedData = this.dataAggregator.getAggregatedData();
|
|
const signals: TradingSignal[] = [];
|
|
const signals: TradingSignal[] = [];
|
|
|
|
|
|
- let maxUtilization = 0;
|
|
|
|
|
|
+ const metricsContext = this.getUtilizationContext();
|
|
|
|
+ const regime = metricsContext.regime;
|
|
|
|
+ const metrics = metricsContext.metrics;
|
|
|
|
+
|
|
|
|
+ const forceReduce = options?.forceReduce ?? false;
|
|
|
|
+ const reduceFraction = Math.min(Math.max(options?.reduceFraction ?? 0.5, 0.05), 1);
|
|
|
|
+ const emergencyMinNotional = options?.minNotional ?? this.minOrderValue;
|
|
|
|
+ const allowMarketOrders = options?.allowMarketOrders ?? false;
|
|
|
|
+
|
|
|
|
+ this.logger.debug('Exposure utilization context', {
|
|
|
|
+ regime,
|
|
|
|
+ baseEquity: metrics.baseEquity,
|
|
|
|
+ baseAvailable: metrics.baseAvailable,
|
|
|
|
+ maxUtilization: metrics.maxUtilization.toFixed(3)
|
|
|
|
+ });
|
|
|
|
+
|
|
const marginUtilizations = new Map<string, number>();
|
|
const marginUtilizations = new Map<string, number>();
|
|
const exposuresByAccount = new Map<string, Map<string, number>>();
|
|
const exposuresByAccount = new Map<string, Map<string, number>>();
|
|
const symbolTotals = new Map<string, number>();
|
|
const symbolTotals = new Map<string, number>();
|
|
@@ -818,7 +1023,6 @@ export class MonitoringManager {
|
|
utilization = Math.min(Math.max(1 - available / balance.total, 0), 1);
|
|
utilization = Math.min(Math.max(1 - available / balance.total, 0), 1);
|
|
}
|
|
}
|
|
marginUtilizations.set(accountId, utilization);
|
|
marginUtilizations.set(accountId, utilization);
|
|
- maxUtilization = Math.max(maxUtilization, utilization);
|
|
|
|
|
|
|
|
const accountExposureMap = new Map<string, number>();
|
|
const accountExposureMap = new Map<string, number>();
|
|
exposuresByAccount.set(accountId, accountExposureMap);
|
|
exposuresByAccount.set(accountId, accountExposureMap);
|
|
@@ -829,22 +1033,19 @@ export class MonitoringManager {
|
|
if (!symbol) {
|
|
if (!symbol) {
|
|
continue;
|
|
continue;
|
|
}
|
|
}
|
|
- const size = typeof pos.amount === 'number' ? pos.amount : 0;
|
|
|
|
|
|
+ const size = this.normalizePositionSize(pos);
|
|
if (!Number.isFinite(size) || size === 0) {
|
|
if (!Number.isFinite(size) || size === 0) {
|
|
continue;
|
|
continue;
|
|
}
|
|
}
|
|
-
|
|
|
|
accountExposureMap.set(symbol, (accountExposureMap.get(symbol) ?? 0) + size);
|
|
accountExposureMap.set(symbol, (accountExposureMap.get(symbol) ?? 0) + size);
|
|
symbolTotals.set(symbol, (symbolTotals.get(symbol) ?? 0) + size);
|
|
symbolTotals.set(symbol, (symbolTotals.get(symbol) ?? 0) + size);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
|
|
- const shouldOpen = maxUtilization < 0.5;
|
|
|
|
-
|
|
|
|
const MIN_ORDER_SIZE = 0.00001;
|
|
const MIN_ORDER_SIZE = 0.00001;
|
|
|
|
|
|
- if (shouldOpen) {
|
|
|
|
|
|
+ if (!forceReduce && regime !== 'reduce') {
|
|
symbolTotals.forEach((totalExposure, symbol) => {
|
|
symbolTotals.forEach((totalExposure, symbol) => {
|
|
if (Math.abs(totalExposure) < MIN_ORDER_SIZE) {
|
|
if (Math.abs(totalExposure) < MIN_ORDER_SIZE) {
|
|
return;
|
|
return;
|
|
@@ -858,51 +1059,127 @@ export class MonitoringManager {
|
|
);
|
|
);
|
|
|
|
|
|
if (!targetAccountId) {
|
|
if (!targetAccountId) {
|
|
- this.logger.warn('Unable to select account for exposure rebalance (open)', {
|
|
|
|
- symbol,
|
|
|
|
- totalExposure
|
|
|
|
- });
|
|
|
|
|
|
+ this.logger.warn('Unable to select account for exposure rebalance (open)', { symbol, totalExposure });
|
|
return;
|
|
return;
|
|
}
|
|
}
|
|
|
|
|
|
const priceData: PriceTick | undefined = aggregatedData.market.prices.get(symbol);
|
|
const priceData: PriceTick | undefined = aggregatedData.market.prices.get(symbol);
|
|
const currentPrice = this.extractPrice(priceData);
|
|
const currentPrice = this.extractPrice(priceData);
|
|
-
|
|
|
|
|
|
+ let amount = Math.abs(totalExposure);
|
|
|
|
+ amount = this.clampExposureAmount(amount, currentPrice, metrics, regime);
|
|
|
|
+ if (amount < MIN_ORDER_SIZE) {
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
const openSide: 'bid' | 'ask' = totalExposure > 0 ? 'ask' : 'bid';
|
|
const openSide: 'bid' | 'ask' = totalExposure > 0 ? 'ask' : 'bid';
|
|
- const amount = Math.abs(totalExposure);
|
|
|
|
-
|
|
|
|
- signals.push({
|
|
|
|
- type: 'open',
|
|
|
|
- accountId: targetAccountId,
|
|
|
|
- symbol,
|
|
|
|
- side: openSide,
|
|
|
|
- amount,
|
|
|
|
- price: currentPrice,
|
|
|
|
- useMarketOrder: true,
|
|
|
|
- reason: `exposure_rebalance_open: ${reasonText}`,
|
|
|
|
|
|
+
|
|
|
|
+ signals.push({
|
|
|
|
+ type: 'open',
|
|
|
|
+ accountId: targetAccountId,
|
|
|
|
+ symbol,
|
|
|
|
+ side: openSide,
|
|
|
|
+ amount,
|
|
|
|
+ price: currentPrice,
|
|
|
|
+ useMarketOrder: allowMarketOrders,
|
|
|
|
+ reason: `exposure_rebalance_open: ${reasonText}`,
|
|
timestamp: Date.now(),
|
|
timestamp: Date.now(),
|
|
- metadata: {
|
|
|
|
- rebalance: true
|
|
|
|
- }
|
|
|
|
|
|
+ metadata: { rebalance: true }
|
|
});
|
|
});
|
|
});
|
|
});
|
|
- } else {
|
|
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if (forceReduce) {
|
|
|
|
+ symbolTotals.forEach((totalExposure, symbol) => {
|
|
|
|
+ if (Math.abs(totalExposure) < MIN_ORDER_SIZE) {
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const priceData: PriceTick | undefined = aggregatedData.market.prices.get(symbol);
|
|
|
|
+ const currentPrice = this.extractPrice(priceData);
|
|
|
|
+ if (!(currentPrice > 0)) {
|
|
|
|
+ this.logger.warn('Unable to perform emergency reduction without price data', { symbol });
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const isNetLong = totalExposure > 0;
|
|
|
|
+ let remaining = Math.abs(totalExposure);
|
|
|
|
+ const minAmount = Math.max(emergencyMinNotional / currentPrice, MIN_ORDER_SIZE);
|
|
|
|
+
|
|
|
|
+ const candidateAccounts = Array.from(exposuresByAccount.entries())
|
|
|
|
+ .map(([accountId, exposureMap]) => ({
|
|
|
|
+ accountId,
|
|
|
|
+ size: exposureMap.get(symbol) ?? 0
|
|
|
|
+ }))
|
|
|
|
+ .filter(entry => isNetLong ? entry.size > MIN_ORDER_SIZE : entry.size < -MIN_ORDER_SIZE)
|
|
|
|
+ .sort((a, b) => isNetLong ? b.size - a.size : a.size - b.size);
|
|
|
|
+
|
|
|
|
+ for (const { accountId, size } of candidateAccounts) {
|
|
|
|
+ if (remaining <= MIN_ORDER_SIZE) {
|
|
|
|
+ break;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const available = Math.min(Math.abs(size), remaining);
|
|
|
|
+ let amount = Math.max(available, minAmount);
|
|
|
|
+ amount = Math.min(amount, Math.abs(size));
|
|
|
|
+ if (amount < MIN_ORDER_SIZE) {
|
|
|
|
+ continue;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const side: 'bid' | 'ask' = isNetLong ? 'ask' : 'bid';
|
|
|
|
+ const price = allowMarketOrders
|
|
|
|
+ ? currentPrice
|
|
|
|
+ : isNetLong
|
|
|
|
+ ? currentPrice * 0.998
|
|
|
|
+ : currentPrice * 1.002;
|
|
|
|
+
|
|
|
|
+ signals.push({
|
|
|
|
+ type: 'reduce',
|
|
|
|
+ accountId,
|
|
|
|
+ symbol,
|
|
|
|
+ side,
|
|
|
|
+ amount,
|
|
|
|
+ price,
|
|
|
|
+ useMarketOrder: allowMarketOrders,
|
|
|
|
+ reason: `emergency_force_reduce: ${reasonText}`,
|
|
|
|
+ timestamp: Date.now()
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ remaining = Math.max(0, remaining - amount);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if (remaining > MIN_ORDER_SIZE) {
|
|
|
|
+ this.logger.warn('Residual exposure remains after emergency reduction attempt', {
|
|
|
|
+ symbol,
|
|
|
|
+ remaining
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+ });
|
|
|
|
+ } else if (regime !== 'build') {
|
|
aggregatedData.accounts.forEach((accountData, accountId) => {
|
|
aggregatedData.accounts.forEach((accountData, accountId) => {
|
|
if (!Array.isArray(accountData.positions) || accountData.positions.length === 0) {
|
|
if (!Array.isArray(accountData.positions) || accountData.positions.length === 0) {
|
|
return;
|
|
return;
|
|
}
|
|
}
|
|
|
|
|
|
for (const pos of accountData.positions) {
|
|
for (const pos of accountData.positions) {
|
|
- const positionSize = typeof pos.amount === 'number' ? pos.amount : 0;
|
|
|
|
|
|
+ const positionSize = this.normalizePositionSize(pos);
|
|
if (positionSize === 0) {
|
|
if (positionSize === 0) {
|
|
continue;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
|
|
const priceData: PriceTick | undefined = aggregatedData.market.prices.get(pos.symbol);
|
|
const priceData: PriceTick | undefined = aggregatedData.market.prices.get(pos.symbol);
|
|
const currentPrice = this.extractPrice(priceData);
|
|
const currentPrice = this.extractPrice(priceData);
|
|
|
|
+ const clampRegime = regime === 'reduce' ? 'reduce' : 'balance';
|
|
|
|
+ const initialAmount = Math.max(Math.abs(positionSize) * reduceFraction, MIN_ORDER_SIZE);
|
|
|
|
+ let amount = this.clampExposureAmount(initialAmount, currentPrice, metrics, clampRegime);
|
|
|
|
+
|
|
|
|
+ if (amount > Math.abs(positionSize)) {
|
|
|
|
+ amount = Math.abs(positionSize);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if (amount < MIN_ORDER_SIZE) {
|
|
|
|
+ continue;
|
|
|
|
+ }
|
|
const side: 'bid' | 'ask' = positionSize > 0 ? 'ask' : 'bid';
|
|
const side: 'bid' | 'ask' = positionSize > 0 ? 'ask' : 'bid';
|
|
- const amount = Math.max(Math.abs(positionSize) * 0.5, MIN_ORDER_SIZE);
|
|
|
|
- const price = positionSize > 0 ? currentPrice * 0.998 : currentPrice * 1.002;
|
|
|
|
|
|
+ const aggressivePrice = positionSize > 0 ? currentPrice * 0.998 : currentPrice * 1.002;
|
|
|
|
|
|
signals.push({
|
|
signals.push({
|
|
type: 'reduce',
|
|
type: 'reduce',
|
|
@@ -910,7 +1187,7 @@ export class MonitoringManager {
|
|
symbol: pos.symbol,
|
|
symbol: pos.symbol,
|
|
side,
|
|
side,
|
|
amount,
|
|
amount,
|
|
- price,
|
|
|
|
|
|
+ price: aggressivePrice,
|
|
reason: `exposure_rebalance_reduce: ${reasonText}`,
|
|
reason: `exposure_rebalance_reduce: ${reasonText}`,
|
|
timestamp: Date.now()
|
|
timestamp: Date.now()
|
|
});
|
|
});
|
|
@@ -920,7 +1197,86 @@ export class MonitoringManager {
|
|
|
|
|
|
if (signals.length > 0) {
|
|
if (signals.length > 0) {
|
|
await this.signalExecutor.executeSignalsBatch(signals);
|
|
await this.signalExecutor.executeSignalsBatch(signals);
|
|
|
|
+
|
|
|
|
+ if (options?.forceReduce) {
|
|
|
|
+ const accountsToRefresh = new Set<string>(signals.map(signal => signal.accountId));
|
|
|
|
+ await this.refreshAccounts(accountsToRefresh);
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private normalizePositionSize(position: any): number {
|
|
|
|
+ const raw = typeof position?.amount === 'number'
|
|
|
|
+ ? position.amount
|
|
|
|
+ : Number(position?.amount ?? position?.size ?? 0);
|
|
|
|
+ if (!Number.isFinite(raw) || raw === 0) {
|
|
|
|
+ return 0;
|
|
|
|
+ }
|
|
|
|
+ const side = position?.side;
|
|
|
|
+ if (side === 'ask') {
|
|
|
|
+ return raw > 0 ? -raw : raw;
|
|
|
|
+ }
|
|
|
|
+ if (side === 'bid') {
|
|
|
|
+ return raw < 0 ? -raw : raw;
|
|
|
|
+ }
|
|
|
|
+ return raw;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private async refreshAccounts(accountIds: Iterable<string>): Promise<void> {
|
|
|
|
+ const unique = Array.from(new Set(accountIds));
|
|
|
|
+ if (unique.length === 0) {
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ await Promise.all(unique.map(async accountId => {
|
|
|
|
+ try {
|
|
|
|
+ await this.accountManager.refreshAccountData(accountId);
|
|
|
|
+ } catch (error) {
|
|
|
|
+ this.logger.warn('Failed to refresh account during emergency mitigation', {
|
|
|
|
+ accountId,
|
|
|
|
+ error
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+ }));
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private computeSymbolNetExposure(symbol: string): { netSize: number; currentPrice: number } {
|
|
|
|
+ const aggregatedData = this.dataAggregator.getAggregatedData();
|
|
|
|
+ let netSize = 0;
|
|
|
|
+
|
|
|
|
+ aggregatedData.accounts.forEach(accountData => {
|
|
|
|
+ const positions = Array.isArray(accountData.positions) ? accountData.positions : [];
|
|
|
|
+ for (const pos of positions) {
|
|
|
|
+ if (!pos || pos.symbol !== symbol) {
|
|
|
|
+ continue;
|
|
|
|
+ }
|
|
|
|
+ netSize += this.normalizePositionSize(pos);
|
|
|
|
+ }
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ const priceData: PriceTick | undefined = aggregatedData.market.prices.get(symbol);
|
|
|
|
+ const currentPrice = this.extractPrice(priceData);
|
|
|
|
+
|
|
|
|
+ return { netSize, currentPrice };
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ private resolveEmergencyNetThreshold(currentPrice: number): number {
|
|
|
|
+ const configured = this.emergencyMitigationConfig?.netExposureThreshold;
|
|
|
|
+ if (configured && configured > 0) {
|
|
|
|
+ return configured;
|
|
|
|
+ }
|
|
|
|
+ if (currentPrice > 0) {
|
|
|
|
+ const minNotional = this.emergencyMitigationConfig?.minNotional ?? this.minOrderValue;
|
|
|
|
+ return Math.max(minNotional / currentPrice, 0.00001);
|
|
|
|
+ }
|
|
|
|
+ return 0.00001;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ public clearEmergencyMitigation(): void {
|
|
|
|
+ if (this.emergencyReductionActive) {
|
|
|
|
+ this.logger.info('Emergency mitigation cleared, resuming normal exposure handling');
|
|
}
|
|
}
|
|
|
|
+ this.emergencyReductionActive = false;
|
|
}
|
|
}
|
|
|
|
|
|
private selectAccountForExposureOpen(
|
|
private selectAccountForExposureOpen(
|