mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
feat: 更新网格引擎,重构订单管理逻辑,添加持仓水平和关闭目标管理,优化订单计算和曝光对齐处理
This commit is contained in:
+247
-122
@@ -26,6 +26,14 @@ interface DesiredGridOrder {
|
||||
reduceOnly: boolean;
|
||||
}
|
||||
|
||||
interface LevelMeta {
|
||||
index: number;
|
||||
price: number;
|
||||
side: "BUY" | "SELL";
|
||||
closeTarget: number | null;
|
||||
closeSources: number[];
|
||||
}
|
||||
|
||||
interface GridLineSnapshot {
|
||||
level: number;
|
||||
price: number;
|
||||
@@ -78,11 +86,16 @@ export class GridEngine {
|
||||
private readonly now: () => number;
|
||||
private readonly configValid: boolean;
|
||||
private readonly gridLevels: number[];
|
||||
private readonly levelExposure = new Map<number, number>();
|
||||
private readonly lastOrderBook = new Map<string, { side: "BUY" | "SELL"; level: number; quantity: number }>();
|
||||
private readonly levelMeta: LevelMeta[] = [];
|
||||
private readonly buyLevelIndices: number[] = [];
|
||||
private readonly sellLevelIndices: number[] = [];
|
||||
private readonly longExposure = new Map<number, number>();
|
||||
private readonly shortExposure = new Map<number, number>();
|
||||
private readonly lastOrderBook = new Map<
|
||||
string,
|
||||
{ side: "BUY" | "SELL"; level: number; quantity: number; reduceOnly: boolean }
|
||||
>();
|
||||
private readonly pendingCancelKeys = new Set<string>();
|
||||
private readonly buyLevelIndices: number[];
|
||||
private readonly sellLevelIndices: number[];
|
||||
|
||||
private accountSnapshot: AsterAccountSnapshot | null = null;
|
||||
private depthSnapshot: AsterDepth | null = null;
|
||||
@@ -121,23 +134,7 @@ export class GridEngine {
|
||||
this.now = options.now ?? Date.now;
|
||||
this.configValid = this.validateConfig();
|
||||
this.gridLevels = this.computeGridLevels();
|
||||
const pivotIndex = Math.floor(Math.max(this.gridLevels.length - 1, 0) / 2);
|
||||
this.buyLevelIndices = [];
|
||||
this.sellLevelIndices = [];
|
||||
for (let i = 0; i < this.gridLevels.length; i += 1) {
|
||||
if (i <= pivotIndex) {
|
||||
this.buyLevelIndices.push(i);
|
||||
}
|
||||
if (i >= pivotIndex + 1) {
|
||||
this.sellLevelIndices.push(i);
|
||||
}
|
||||
}
|
||||
if (!this.sellLevelIndices.length && this.gridLevels.length > 1) {
|
||||
const highest = this.gridLevels.length - 1;
|
||||
if (!this.sellLevelIndices.includes(highest)) {
|
||||
this.sellLevelIndices.push(highest);
|
||||
}
|
||||
}
|
||||
this.buildLevelMeta();
|
||||
this.running = this.configValid;
|
||||
if (!this.configValid) {
|
||||
this.stopReason = "配置无效,已暂停网格";
|
||||
@@ -352,7 +349,8 @@ export class GridEngine {
|
||||
this.desiredOrders = [];
|
||||
this.lastUpdated = this.now();
|
||||
this.running = false;
|
||||
this.levelExposure.clear();
|
||||
this.longExposure.clear();
|
||||
this.shortExposure.clear();
|
||||
this.lastOrderBook.clear();
|
||||
this.pendingCancelKeys.clear();
|
||||
}
|
||||
@@ -406,24 +404,32 @@ export class GridEngine {
|
||||
const desired = this.computeDesiredOrders(price);
|
||||
this.desiredOrders = desired;
|
||||
|
||||
const desiredKeys = new Set(desired.map((order) => this.getOrderKey(order.side, order.price)));
|
||||
const desiredKeys = new Set(desired.map((order) => this.getOrderKey(order.side, order.price, order.reduceOnly)));
|
||||
const activeOrders = this.openOrders.filter((order) => order.symbol === this.config.symbol && order.type === "LIMIT");
|
||||
const orderMap = new Map<string, AsterOrder>();
|
||||
const orderBookEntries = new Map<string, { side: "BUY" | "SELL"; level: number; quantity: number }>();
|
||||
const orderBookEntries = new Map<
|
||||
string,
|
||||
{ side: "BUY" | "SELL"; level: number; quantity: number; reduceOnly: boolean }
|
||||
>();
|
||||
for (const order of activeOrders) {
|
||||
const key = this.getOrderKey(order.side, this.normalizePrice(order.price));
|
||||
const key = this.getOrderKey(order.side, this.normalizePrice(order.price), order.reduceOnly === true);
|
||||
orderMap.set(key, order);
|
||||
const level = this.resolveLevelIndex(Number(order.price));
|
||||
if (level != null) {
|
||||
const quantity = Math.max(0, Number(order.origQty) - Number(order.executedQty ?? 0));
|
||||
orderBookEntries.set(key, { side: order.side, level, quantity });
|
||||
orderBookEntries.set(key, {
|
||||
side: order.side,
|
||||
level,
|
||||
quantity,
|
||||
reduceOnly: order.reduceOnly === true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.updateLevelExposure(orderBookEntries);
|
||||
|
||||
for (const order of activeOrders) {
|
||||
const key = this.getOrderKey(order.side, this.normalizePrice(order.price));
|
||||
const key = this.getOrderKey(order.side, this.normalizePrice(order.price), order.reduceOnly === true);
|
||||
if (desiredKeys.has(key)) continue;
|
||||
this.pendingCancelKeys.add(key);
|
||||
await safeCancelOrder(
|
||||
@@ -448,7 +454,7 @@ export class GridEngine {
|
||||
}
|
||||
|
||||
for (const desiredOrder of desired) {
|
||||
const key = this.getOrderKey(desiredOrder.side, desiredOrder.price);
|
||||
const key = this.getOrderKey(desiredOrder.side, desiredOrder.price, desiredOrder.reduceOnly);
|
||||
if (orderMap.has(key)) continue;
|
||||
try {
|
||||
const placed = await placeOrder(
|
||||
@@ -481,12 +487,10 @@ export class GridEngine {
|
||||
if (!this.running || !this.gridLevels.length || !this.configValid) return [];
|
||||
this.alignExposureWithPosition();
|
||||
const desired: DesiredGridOrder[] = [];
|
||||
const maxLongExposure = Math.max(this.config.maxPositionSize - Math.max(this.position.positionAmt, 0), 0);
|
||||
const maxShortExposure = Math.max(this.config.maxPositionSize - Math.max(-this.position.positionAmt, 0), 0);
|
||||
let remainingLongHeadroom = maxLongExposure;
|
||||
let remainingShortHeadroom = maxShortExposure;
|
||||
let availableToSell = Math.max(this.position.positionAmt, 0);
|
||||
let availableToBuy = Math.max(-this.position.positionAmt, 0);
|
||||
const totalLongExposure = this.sumExposure(this.longExposure);
|
||||
const totalShortExposure = this.sumExposure(this.shortExposure);
|
||||
let remainingLongHeadroom = Math.max(this.config.maxPositionSize - totalLongExposure, 0);
|
||||
let remainingShortHeadroom = Math.max(this.config.maxPositionSize - totalShortExposure, 0);
|
||||
|
||||
const halfTick = this.config.priceTick / 2;
|
||||
const belowPrice = this.buyLevelIndices
|
||||
@@ -498,49 +502,73 @@ export class GridEngine {
|
||||
.filter(({ levelPrice }) => levelPrice > price + halfTick)
|
||||
.sort((a, b) => a.levelPrice - b.levelPrice);
|
||||
|
||||
for (const { level, levelPrice } of belowPrice) {
|
||||
const amount = this.config.orderSize;
|
||||
const reduceOnly = this.config.direction === "short";
|
||||
const held = this.levelExposure.get(level) ?? 0;
|
||||
if (held >= amount - EPSILON) {
|
||||
continue;
|
||||
}
|
||||
if (!reduceOnly) {
|
||||
if (remainingLongHeadroom < amount - EPSILON) break;
|
||||
remainingLongHeadroom -= amount;
|
||||
} else {
|
||||
if (availableToBuy < amount - EPSILON) continue;
|
||||
availableToBuy -= amount;
|
||||
}
|
||||
desired.push({
|
||||
level,
|
||||
side: "BUY",
|
||||
price: this.formatPrice(levelPrice),
|
||||
amount,
|
||||
reduceOnly,
|
||||
});
|
||||
const longCloseRequirements = new Map<number, number>();
|
||||
for (const [level, exposure] of this.longExposure) {
|
||||
if (exposure <= EPSILON) continue;
|
||||
const targetIndex = this.levelMeta[level]?.closeTarget;
|
||||
if (targetIndex == null) continue;
|
||||
longCloseRequirements.set(targetIndex, (longCloseRequirements.get(targetIndex) ?? 0) + exposure);
|
||||
}
|
||||
|
||||
for (const { level, levelPrice } of abovePrice) {
|
||||
const amount = this.config.orderSize;
|
||||
const reduceOnly = this.config.direction === "long";
|
||||
const heldLong = this.levelExposure.get(level) ?? 0;
|
||||
if (!reduceOnly && heldLong > EPSILON) {
|
||||
continue;
|
||||
const shortCloseRequirements = new Map<number, number>();
|
||||
for (const [level, exposure] of this.shortExposure) {
|
||||
if (exposure <= EPSILON) continue;
|
||||
const targetIndex = this.levelMeta[level]?.closeTarget;
|
||||
if (targetIndex == null) continue;
|
||||
shortCloseRequirements.set(targetIndex, (shortCloseRequirements.get(targetIndex) ?? 0) + exposure);
|
||||
}
|
||||
|
||||
if (this.config.direction !== "short") {
|
||||
for (const { level, levelPrice } of belowPrice) {
|
||||
if (shortCloseRequirements.has(level)) continue;
|
||||
const exposure = this.longExposure.get(level) ?? 0;
|
||||
if (exposure >= this.config.orderSize - EPSILON) continue;
|
||||
if (remainingLongHeadroom < this.config.orderSize - EPSILON) continue;
|
||||
desired.push({
|
||||
level,
|
||||
side: "BUY",
|
||||
price: this.formatPrice(levelPrice),
|
||||
amount: this.config.orderSize,
|
||||
reduceOnly: false,
|
||||
});
|
||||
remainingLongHeadroom -= this.config.orderSize;
|
||||
}
|
||||
if (!reduceOnly) {
|
||||
if (remainingShortHeadroom < amount - EPSILON) break;
|
||||
remainingShortHeadroom -= amount;
|
||||
} else {
|
||||
if (availableToSell < amount - EPSILON) continue;
|
||||
availableToSell -= amount;
|
||||
}
|
||||
|
||||
if (this.config.direction !== "long") {
|
||||
for (const { level, levelPrice } of abovePrice) {
|
||||
if (longCloseRequirements.has(level)) continue;
|
||||
const exposure = this.shortExposure.get(level) ?? 0;
|
||||
if (exposure >= this.config.orderSize - EPSILON) continue;
|
||||
if (remainingShortHeadroom < this.config.orderSize - EPSILON) continue;
|
||||
desired.push({
|
||||
level,
|
||||
side: "SELL",
|
||||
price: this.formatPrice(levelPrice),
|
||||
amount: this.config.orderSize,
|
||||
reduceOnly: false,
|
||||
});
|
||||
remainingShortHeadroom -= this.config.orderSize;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [level, quantity] of longCloseRequirements) {
|
||||
desired.push({
|
||||
level,
|
||||
side: "SELL",
|
||||
price: this.formatPrice(levelPrice),
|
||||
amount,
|
||||
reduceOnly,
|
||||
price: this.formatPrice(this.gridLevels[level]!),
|
||||
amount: quantity,
|
||||
reduceOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [level, quantity] of shortCloseRequirements) {
|
||||
desired.push({
|
||||
level,
|
||||
side: "BUY",
|
||||
price: this.formatPrice(this.gridLevels[level]!),
|
||||
amount: quantity,
|
||||
reduceOnly: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -568,18 +596,20 @@ export class GridEngine {
|
||||
const tickerLast = Number(this.tickerSnapshot?.lastPrice);
|
||||
const lastPrice = Number.isFinite(tickerLast) ? tickerLast : reference;
|
||||
const midPrice = reference;
|
||||
const desiredKeys = new Set(this.desiredOrders.map((order) => this.getOrderKey(order.side, order.price)));
|
||||
const desiredKeys = new Set(
|
||||
this.desiredOrders.map((order) => this.getOrderKey(order.side, order.price, order.reduceOnly))
|
||||
);
|
||||
const openOrderKeys = new Set(
|
||||
this.openOrders
|
||||
.filter((order) => order.symbol === this.config.symbol && order.type === "LIMIT")
|
||||
.map((order) => this.getOrderKey(order.side, this.normalizePrice(order.price)))
|
||||
.map((order) => this.getOrderKey(order.side, this.normalizePrice(order.price), order.reduceOnly === true))
|
||||
);
|
||||
|
||||
const gridLines: GridLineSnapshot[] = this.gridLevels.map((price, level) => {
|
||||
const desired = this.desiredOrders.find((order) => order.level === level);
|
||||
const defaultSide = this.buyLevelIndices.includes(level) ? "BUY" : "SELL";
|
||||
const side = desired?.side ?? defaultSide;
|
||||
const key = desired ? this.getOrderKey(desired.side, desired.price) : null;
|
||||
const key = desired ? this.getOrderKey(desired.side, desired.price, desired.reduceOnly) : null;
|
||||
const hasOrder = key ? openOrderKeys.has(key) : false;
|
||||
const active = Boolean(desired && key && desiredKeys.has(key));
|
||||
return {
|
||||
@@ -616,8 +646,8 @@ export class GridEngine {
|
||||
this.events.emit("update", this.buildSnapshot());
|
||||
}
|
||||
|
||||
private getOrderKey(side: "BUY" | "SELL", price: string): string {
|
||||
return `${side}:${price}`;
|
||||
private getOrderKey(side: "BUY" | "SELL", price: string, reduceOnly = false): string {
|
||||
return `${side}:${price}:${reduceOnly ? "RO" : "OPEN"}`;
|
||||
}
|
||||
|
||||
private normalizePrice(price: string | number): string {
|
||||
@@ -640,22 +670,51 @@ export class GridEngine {
|
||||
return null;
|
||||
}
|
||||
|
||||
private updateLevelExposure(currentOrders: Map<string, { side: "BUY" | "SELL"; level: number; quantity: number }>): void {
|
||||
const previousEntries = new Map(this.lastOrderBook);
|
||||
for (const [key, previous] of previousEntries) {
|
||||
private buildLevelMeta(): void {
|
||||
this.levelMeta.length = 0;
|
||||
this.buyLevelIndices.length = 0;
|
||||
this.sellLevelIndices.length = 0;
|
||||
if (!this.gridLevels.length) return;
|
||||
const pivotIndex = Math.floor(Math.max(this.gridLevels.length - 1, 0) / 2);
|
||||
for (let i = 0; i < this.gridLevels.length; i += 1) {
|
||||
const side: "BUY" | "SELL" = i <= pivotIndex ? "BUY" : "SELL";
|
||||
const meta: LevelMeta = {
|
||||
index: i,
|
||||
price: this.gridLevels[i]!,
|
||||
side,
|
||||
closeTarget: null,
|
||||
closeSources: [],
|
||||
};
|
||||
this.levelMeta.push(meta);
|
||||
if (side === "BUY") this.buyLevelIndices.push(i);
|
||||
else this.sellLevelIndices.push(i);
|
||||
}
|
||||
for (const meta of this.levelMeta) {
|
||||
if (meta.side === "BUY") {
|
||||
const target = this.levelMeta.find((candidate) => candidate.index > meta.index && candidate.side === "SELL");
|
||||
meta.closeTarget = target?.index ?? null;
|
||||
if (target) target.closeSources.push(meta.index);
|
||||
} else {
|
||||
for (let j = meta.index - 1; j >= 0; j -= 1) {
|
||||
if (this.levelMeta[j]!.side === "BUY") {
|
||||
meta.closeTarget = this.levelMeta[j]!.index;
|
||||
this.levelMeta[j]!.closeSources.push(meta.index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateLevelExposure(
|
||||
currentOrders: Map<string, { side: "BUY" | "SELL"; level: number; quantity: number; reduceOnly: boolean }>
|
||||
): void {
|
||||
for (const [key, previous] of this.lastOrderBook) {
|
||||
const current = currentOrders.get(key);
|
||||
if (current) {
|
||||
const delta = previous.quantity - current.quantity;
|
||||
if (Math.abs(delta) > EPSILON) {
|
||||
if (previous.side === "BUY") {
|
||||
const held = this.levelExposure.get(previous.level) ?? 0;
|
||||
this.levelExposure.set(previous.level, held + Math.max(0, delta));
|
||||
} else {
|
||||
const held = this.levelExposure.get(previous.level) ?? 0;
|
||||
const next = held - Math.max(0, delta);
|
||||
if (next <= EPSILON) this.levelExposure.delete(previous.level);
|
||||
else this.levelExposure.set(previous.level, next);
|
||||
}
|
||||
this.applyFillDelta(previous, delta);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -663,18 +722,7 @@ export class GridEngine {
|
||||
this.pendingCancelKeys.delete(key);
|
||||
continue;
|
||||
}
|
||||
if (previous.quantity <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (previous.side === "BUY") {
|
||||
const held = this.levelExposure.get(previous.level) ?? 0;
|
||||
this.levelExposure.set(previous.level, held + previous.quantity);
|
||||
} else {
|
||||
const held = this.levelExposure.get(previous.level) ?? 0;
|
||||
const next = held - previous.quantity;
|
||||
if (next <= EPSILON) this.levelExposure.delete(previous.level);
|
||||
else this.levelExposure.set(previous.level, next);
|
||||
}
|
||||
this.applyFillDelta(previous, previous.quantity);
|
||||
}
|
||||
this.lastOrderBook.clear();
|
||||
for (const [key, entry] of currentOrders) {
|
||||
@@ -682,34 +730,111 @@ export class GridEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private alignExposureWithPosition(): void {
|
||||
const totalHeld = Array.from(this.levelExposure.values()).reduce((acc, qty) => acc + qty, 0);
|
||||
const actualLong = Math.max(this.position.positionAmt, 0);
|
||||
if (Math.abs(totalHeld - actualLong) <= EPSILON) return;
|
||||
if (actualLong <= EPSILON) {
|
||||
this.levelExposure.clear();
|
||||
private applyFillDelta(
|
||||
entry: { side: "BUY" | "SELL"; level: number; quantity: number; reduceOnly: boolean },
|
||||
filled: number
|
||||
): void {
|
||||
if (filled <= EPSILON) return;
|
||||
if (entry.reduceOnly) {
|
||||
if (entry.side === "SELL") {
|
||||
this.consumeLongExposure(entry.level, filled);
|
||||
} else {
|
||||
this.consumeShortExposure(entry.level, filled);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let remaining = actualLong;
|
||||
const levels = Array.from(this.levelExposure.keys()).sort((a, b) => a - b);
|
||||
for (const level of levels) {
|
||||
if (remaining <= EPSILON) {
|
||||
this.levelExposure.delete(level);
|
||||
continue;
|
||||
}
|
||||
const current = this.levelExposure.get(level) ?? 0;
|
||||
if (current <= remaining + EPSILON) {
|
||||
this.levelExposure.set(level, current);
|
||||
remaining -= current;
|
||||
} else {
|
||||
this.levelExposure.set(level, remaining);
|
||||
remaining = 0;
|
||||
if (entry.side === "BUY") {
|
||||
this.incrementLongExposure(entry.level, filled);
|
||||
} else {
|
||||
this.incrementShortExposure(entry.level, filled);
|
||||
}
|
||||
}
|
||||
|
||||
private alignExposureWithPosition(): void {
|
||||
const actualLong = Math.max(this.position.positionAmt, 0);
|
||||
const trackedLong = this.sumExposure(this.longExposure);
|
||||
if (Math.abs(trackedLong - actualLong) > EPSILON) {
|
||||
this.longExposure.clear();
|
||||
let remaining = actualLong;
|
||||
for (const level of [...this.buyLevelIndices].sort((a, b) => b - a)) {
|
||||
if (remaining <= EPSILON) break;
|
||||
const qty = Math.min(this.config.orderSize, remaining);
|
||||
if (qty > EPSILON) {
|
||||
this.longExposure.set(level, qty);
|
||||
remaining -= qty;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [level, qty] of this.levelExposure) {
|
||||
if (qty <= EPSILON) {
|
||||
this.levelExposure.delete(level);
|
||||
|
||||
const actualShort = Math.max(-this.position.positionAmt, 0);
|
||||
const trackedShort = this.sumExposure(this.shortExposure);
|
||||
if (Math.abs(trackedShort - actualShort) > EPSILON) {
|
||||
this.shortExposure.clear();
|
||||
let remaining = actualShort;
|
||||
for (const level of [...this.sellLevelIndices].sort((a, b) => a - b)) {
|
||||
if (remaining <= EPSILON) break;
|
||||
const qty = Math.min(this.config.orderSize, remaining);
|
||||
if (qty > EPSILON) {
|
||||
this.shortExposure.set(level, qty);
|
||||
remaining -= qty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private incrementLongExposure(level: number, quantity: number): void {
|
||||
if (quantity <= EPSILON) return;
|
||||
const current = this.longExposure.get(level) ?? 0;
|
||||
const next = Math.min(this.config.orderSize, current + quantity);
|
||||
if (next <= EPSILON) this.longExposure.delete(level);
|
||||
else this.longExposure.set(level, next);
|
||||
}
|
||||
|
||||
private incrementShortExposure(level: number, quantity: number): void {
|
||||
if (quantity <= EPSILON) return;
|
||||
const current = this.shortExposure.get(level) ?? 0;
|
||||
const next = Math.min(this.config.orderSize, current + quantity);
|
||||
if (next <= EPSILON) this.shortExposure.delete(level);
|
||||
else this.shortExposure.set(level, next);
|
||||
}
|
||||
|
||||
private consumeLongExposure(closeLevel: number, quantity: number): void {
|
||||
if (quantity <= EPSILON) return;
|
||||
const sources = this.levelMeta[closeLevel]?.closeSources ?? [];
|
||||
let remaining = quantity;
|
||||
for (const source of [...sources].sort((a, b) => b - a)) {
|
||||
if (remaining <= EPSILON) break;
|
||||
const current = this.longExposure.get(source) ?? 0;
|
||||
if (current <= EPSILON) continue;
|
||||
const consumed = Math.min(current, remaining);
|
||||
const next = current - consumed;
|
||||
if (next <= EPSILON) this.longExposure.delete(source);
|
||||
else this.longExposure.set(source, next);
|
||||
remaining -= consumed;
|
||||
}
|
||||
}
|
||||
|
||||
private consumeShortExposure(closeLevel: number, quantity: number): void {
|
||||
if (quantity <= EPSILON) return;
|
||||
const sources = this.levelMeta[closeLevel]?.closeSources ?? [];
|
||||
let remaining = quantity;
|
||||
for (const source of [...sources].sort((a, b) => a - b)) {
|
||||
if (remaining <= EPSILON) break;
|
||||
const current = this.shortExposure.get(source) ?? 0;
|
||||
if (current <= EPSILON) continue;
|
||||
const consumed = Math.min(current, remaining);
|
||||
const next = current - consumed;
|
||||
if (next <= EPSILON) this.shortExposure.delete(source);
|
||||
else this.shortExposure.set(source, next);
|
||||
remaining -= consumed;
|
||||
}
|
||||
}
|
||||
|
||||
private sumExposure(storage: Map<number, number>): number {
|
||||
let total = 0;
|
||||
for (const value of storage.values()) {
|
||||
total += value;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user