|
|
@@ -0,0 +1,2016 @@
|
|
|
+# M1.6 Workstream 3: Fill-Driven Adaptive Tightening Design
|
|
|
+
|
|
|
+> **文档版本**: v1.0
|
|
|
+> **创建日期**: 2025-10-09
|
|
|
+> **依赖文档**: `M16_INCREMENTAL_GRID_DESIGN.md`, `M16_PLACEMENT_THROTTLING_DESIGN.md`, `CONFIG_REFERENCE.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 1. 问题背景与动机
|
|
|
+
|
|
|
+### 1.1 实盘观察到的成交缺失现象
|
|
|
+
|
|
|
+从实际运行日志分析(`logs/runner-2025-10-08T14-08-47-352Z.log`)中发现:
|
|
|
+
|
|
|
+```json
|
|
|
+{"level":30,"time":1759932589213,"component":"GridMaker","symbol":"BTC","filledGrids":0,"totalOrders":20,"msg":"Grid status"}
|
|
|
+{"level":30,"time":1759932649215,"component":"GridMaker","symbol":"BTC","filledGrids":0,"totalOrders":14,"msg":"Grid status"}
|
|
|
+{"level":30,"time":1759932709217,"component":"GridMaker","symbol":"BTC","filledGrids":0,"totalOrders":8,"msg":"Grid status"}
|
|
|
+```
|
|
|
+
|
|
|
+**核心问题**:
|
|
|
+1. **成交率为零**:连续 20+ 分钟运行,`filledGrids: 0`,无任何成交
|
|
|
+2. **订单量递减**:自适应步长增大导致层数下降(20→14→8 orders)
|
|
|
+3. **挂单距离过远**:初始步长 30 bps → 调整到 10 bps 后又很快回到 26 bps → 41 bps
|
|
|
+4. **与盘口脱节**:在波动率下降时,网格步长反而增大,导致订单远离成交区
|
|
|
+
|
|
|
+### 1.2 成交缺失的根本原因
|
|
|
+
|
|
|
+#### 原因一:步长与波动率的反向关系失效
|
|
|
+当前自适应逻辑:
|
|
|
+```typescript
|
|
|
+const targetStep = mapVolatilityToStep(hourlyVolBps, minVolBps, maxVolBps, minStepBps, maxStepBps);
|
|
|
+```
|
|
|
+
|
|
|
+**问题**:
|
|
|
+- 波动率计算基于 30 分钟滚动窗口的价格范围
|
|
|
+- 当价格进入盘整期(窄幅震荡)时,`hourlyVolBps` 下降 → `targetStep` **增大**
|
|
|
+- 步长增大导致订单远离当前价格,成交概率进一步降低
|
|
|
+- 形成**负反馈循环**:无成交 → 盘整 → 步长扩大 → 更无成交
|
|
|
+
|
|
|
+#### 原因二:缺少基于实际成交的反馈机制
|
|
|
+现有系统仅根据**市场波动率**调整参数,未考虑**策略自身的成交表现**:
|
|
|
+- 无成交时,系统不知道是"挂单距离过远"还是"市场流动性不足"
|
|
|
+- 成交恢复后,系统无法快速调整回紧密网格
|
|
|
+- 缺少"探索-利用"平衡:无成交时应该**收紧探测成交区域**,而非扩大
|
|
|
+
|
|
|
+#### 原因三:Post-Only 保护与贴盘口的矛盾
|
|
|
+```yaml
|
|
|
+adaptive:
|
|
|
+ post_only_cushion_bps: 2 # 为避免 post-only 拒单,在盘口价差基础上额外后退 2 bps
|
|
|
+```
|
|
|
+
|
|
|
+**权衡困境**:
|
|
|
+- `cushion_bps` 过大 → 订单远离盘口 → 低成交率
|
|
|
+- `cushion_bps` 过小 → post-only 拒单率上升 → 频繁重试 → 触发限流
|
|
|
+- 无成交时需要**动态降低 cushion**,主动靠近盘口探测成交边界
|
|
|
+
|
|
|
+### 1.3 设计目标
|
|
|
+
|
|
|
+引入**成交驱动的自适应机制**,使系统在无成交时主动收紧网格,恢复成交后渐进回调:
|
|
|
+
|
|
|
+| 指标 | 目标 |
|
|
|
+|------|------|
|
|
|
+| **填充率恢复时间** | 连续无成交 5 tick(~3 分钟)后触发首次收紧;10 tick 内恢复成交 |
|
|
|
+| **参数调整幅度** | 单次压缩/扩张步长 ≤ 20%;总压缩幅度 ≤ 50% |
|
|
|
+| **与限流协同** | 收紧动作需经过 ThrottledGateway 批次执行,避免突发请求 |
|
|
|
+| **多实例隔离** | 多账户/标的实例独立决策,通过 `GlobalOrderCoordinator` 检测冲突 |
|
|
|
+| **可观测性** | 新增指标:`fill_count_tick`, `avg_fill_interval_sec`, `compression_factor`, `no_fill_duration_sec` |
|
|
|
+| **可回滚性** | 提供手动/自动禁用开关,防止极端市况下过度激进 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 2. 核心机制设计
|
|
|
+
|
|
|
+### 2.1 成交监控指标
|
|
|
+
|
|
|
+#### 2.1.1 单 Tick 成交计数
|
|
|
+```typescript
|
|
|
+interface FillMetrics {
|
|
|
+ tickIndex: number; // 当前 tick 序号(从启动开始递增)
|
|
|
+ fillCountThisTick: number; // 本 tick 内成交订单数
|
|
|
+ fillVolumeUsd: number; // 本 tick 内成交金额(USD)
|
|
|
+ avgFillIntervalSec: number; // 滚动平均成交间隔(秒)
|
|
|
+ lastFillTimestamp: number; // 上次成交时间戳
|
|
|
+ noFillDurationSec: number; // 距上次成交经过的秒数
|
|
|
+ consecutiveEmptyTicks: number; // 连续无成交 tick 数
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 2.1.2 滚动窗口计算
|
|
|
+```typescript
|
|
|
+class FillRateTracker {
|
|
|
+ private fillHistory: Array<{ ts: number; count: number }> = [];
|
|
|
+ private readonly windowSize = 10; // 最近 10 tick(~5 分钟)
|
|
|
+
|
|
|
+ recordFill(timestamp: number): void {
|
|
|
+ this.fillHistory.push({ ts: timestamp, count: 1 });
|
|
|
+ this.pruneOldEntries(timestamp);
|
|
|
+ }
|
|
|
+
|
|
|
+ private pruneOldEntries(now: number): void {
|
|
|
+ const cutoff = now - this.windowSize * 60_000; // 10 tick × 60s
|
|
|
+ this.fillHistory = this.fillHistory.filter(entry => entry.ts > cutoff);
|
|
|
+ }
|
|
|
+
|
|
|
+ getAvgFillIntervalSec(): number {
|
|
|
+ if (this.fillHistory.length < 2) return Infinity;
|
|
|
+ const sortedTs = this.fillHistory.map(e => e.ts).sort();
|
|
|
+ const intervals = sortedTs.slice(1).map((ts, i) => ts - sortedTs[i]);
|
|
|
+ return intervals.reduce((a, b) => a + b, 0) / intervals.length / 1000;
|
|
|
+ }
|
|
|
+
|
|
|
+ getNoFillDurationSec(now: number): number {
|
|
|
+ if (this.fillHistory.length === 0) return Infinity;
|
|
|
+ const lastFill = Math.max(...this.fillHistory.map(e => e.ts));
|
|
|
+ return (now - lastFill) / 1000;
|
|
|
+ }
|
|
|
+
|
|
|
+ getConsecutiveEmptyTicks(tickIndex: number): number {
|
|
|
+ const recentTicks = new Set(
|
|
|
+ this.fillHistory
|
|
|
+ .filter(e => e.ts > Date.now() - this.windowSize * 60_000)
|
|
|
+ .map(e => Math.floor(e.ts / 60_000)) // 按分钟归类
|
|
|
+ );
|
|
|
+ let count = 0;
|
|
|
+ for (let i = tickIndex; i > tickIndex - this.windowSize && i >= 0; i--) {
|
|
|
+ if (!recentTicks.has(i)) count++;
|
|
|
+ else break;
|
|
|
+ }
|
|
|
+ return count;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 2.2 压缩策略算法
|
|
|
+
|
|
|
+#### 2.2.1 触发条件
|
|
|
+```typescript
|
|
|
+interface CompressionTrigger {
|
|
|
+ consecutiveEmptyTicks: number; // 连续无成交 tick 数
|
|
|
+ noFillDurationSec: number; // 距上次成交秒数
|
|
|
+ currentSpreadBps: number; // 当前盘口价差
|
|
|
+ avgFillIntervalSec: number; // 平均成交间隔
|
|
|
+}
|
|
|
+
|
|
|
+function shouldCompress(metrics: FillMetrics, config: CompressionConfig): boolean {
|
|
|
+ const {
|
|
|
+ fillStarvationThreshold, // 默认 5 tick
|
|
|
+ minNoFillDurationSec, // 默认 180 秒(3 分钟)
|
|
|
+ minSpreadForCompression // 默认 10 bps(价差过窄不压缩)
|
|
|
+ } = config;
|
|
|
+
|
|
|
+ // 条件 1:连续无成交 tick 超过阈值
|
|
|
+ if (metrics.consecutiveEmptyTicks < fillStarvationThreshold) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 条件 2:距上次成交时间足够长
|
|
|
+ if (metrics.noFillDurationSec < minNoFillDurationSec) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 条件 3:盘口价差足够宽(避免在极窄价差时过度贴盘)
|
|
|
+ if (metrics.currentSpreadBps < minSpreadForCompression) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 条件 4:非降级模式(数据断流、对冲失败时禁用压缩)
|
|
|
+ if (this.degradationState.isActive()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ return true;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 2.2.2 压缩执行逻辑
|
|
|
+```typescript
|
|
|
+interface CompressionAction {
|
|
|
+ type: 'step' | 'range' | 'cushion' | 'layers';
|
|
|
+ from: number;
|
|
|
+ to: number;
|
|
|
+ factor: number;
|
|
|
+}
|
|
|
+
|
|
|
+class FillDrivenAdaptation {
|
|
|
+ private compressionFactor = 1.0; // 累积压缩因子(1.0 = 无压缩)
|
|
|
+ private readonly maxCompressionFactor = 0.5; // 最多压缩到 50%
|
|
|
+ private readonly compressionStepSize = 0.85; // 每次压缩 15%
|
|
|
+
|
|
|
+ async compress(metrics: FillMetrics): Promise<CompressionAction[]> {
|
|
|
+ const actions: CompressionAction[] = [];
|
|
|
+
|
|
|
+ // 动作 1:减小 grid_step_bps
|
|
|
+ const currentStep = this.gridMaker.currentGridStepBps;
|
|
|
+ const targetStep = Math.max(
|
|
|
+ this.config.adaptive.minGridStepBps,
|
|
|
+ currentStep * this.compressionStepSize
|
|
|
+ );
|
|
|
+
|
|
|
+ if (targetStep < currentStep - 1e-6) {
|
|
|
+ actions.push({
|
|
|
+ type: 'step',
|
|
|
+ from: currentStep,
|
|
|
+ to: targetStep,
|
|
|
+ factor: targetStep / currentStep
|
|
|
+ });
|
|
|
+ this.logger.info({ currentStep, targetStep }, 'Compressing grid step due to no fills');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 动作 2:收紧 post_only_cushion_bps
|
|
|
+ const currentCushion = this.config.adaptive.postOnlyCushionBps;
|
|
|
+ const targetCushion = Math.max(0, currentCushion - 1);
|
|
|
+ if (targetCushion < currentCushion) {
|
|
|
+ actions.push({
|
|
|
+ type: 'cushion',
|
|
|
+ from: currentCushion,
|
|
|
+ to: targetCushion,
|
|
|
+ factor: targetCushion / (currentCushion || 1)
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 动作 3:增加 min_layers(保持总覆盖档位)
|
|
|
+ const currentMinLayers = this.config.adaptive.minLayers;
|
|
|
+ const targetMinLayers = Math.min(
|
|
|
+ this.config.maxLayers,
|
|
|
+ Math.ceil(currentMinLayers * 1.15)
|
|
|
+ );
|
|
|
+ if (targetMinLayers > currentMinLayers) {
|
|
|
+ actions.push({
|
|
|
+ type: 'layers',
|
|
|
+ from: currentMinLayers,
|
|
|
+ to: targetMinLayers,
|
|
|
+ factor: targetMinLayers / currentMinLayers
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新累积压缩因子
|
|
|
+ this.compressionFactor *= this.compressionStepSize;
|
|
|
+ this.compressionFactor = Math.max(this.maxCompressionFactor, this.compressionFactor);
|
|
|
+
|
|
|
+ return actions;
|
|
|
+ }
|
|
|
+
|
|
|
+ async expand(metrics: FillMetrics): Promise<void> {
|
|
|
+ // 成交恢复后渐进回调
|
|
|
+ if (metrics.fillCountThisTick > 0 && this.compressionFactor < 1.0) {
|
|
|
+ const expansionStep = 1.05; // 每次放宽 5%
|
|
|
+ this.compressionFactor = Math.min(1.0, this.compressionFactor * expansionStep);
|
|
|
+
|
|
|
+ this.logger.info({
|
|
|
+ compressionFactor: this.compressionFactor,
|
|
|
+ fillCount: metrics.fillCountThisTick
|
|
|
+ }, 'Expanding grid parameters after fills');
|
|
|
+
|
|
|
+ // 渐进恢复 grid_step 和 cushion
|
|
|
+ await this.applyExpansion(this.compressionFactor);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async applyExpansion(factor: number): Promise<void> {
|
|
|
+ const baseStep = this.config.gridStepBps;
|
|
|
+ const baseCushion = this.config.adaptive.postOnlyCushionBps;
|
|
|
+
|
|
|
+ const targetStep = baseStep * factor;
|
|
|
+ const targetCushion = Math.round(baseCushion * factor);
|
|
|
+
|
|
|
+ // 使用增量引擎更新网格(避免全撤全布)
|
|
|
+ await this.gridMaker.updateGridParameters({
|
|
|
+ gridStepBps: targetStep,
|
|
|
+ postOnlyCushionBps: targetCushion
|
|
|
+ });
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 2.3 防止过度压缩的保护机制
|
|
|
+
|
|
|
+#### 2.3.1 压缩次数限制
|
|
|
+```typescript
|
|
|
+interface CompressionLimits {
|
|
|
+ maxCompressionsPerWindow: number; // 10 分钟窗口内最多压缩次数
|
|
|
+ windowSizeMinutes: number; // 默认 10 分钟
|
|
|
+ cooldownAfterCompressionSec: number; // 压缩后冷却时间
|
|
|
+}
|
|
|
+
|
|
|
+class CompressionRateLimiter {
|
|
|
+ private compressionHistory: number[] = [];
|
|
|
+
|
|
|
+ canCompress(now: number, limits: CompressionLimits): boolean {
|
|
|
+ this.pruneOldCompressions(now, limits.windowSizeMinutes);
|
|
|
+
|
|
|
+ if (this.compressionHistory.length >= limits.maxCompressionsPerWindow) {
|
|
|
+ this.logger.warn({
|
|
|
+ count: this.compressionHistory.length,
|
|
|
+ limit: limits.maxCompressionsPerWindow
|
|
|
+ }, 'Compression rate limit exceeded');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查冷却时间
|
|
|
+ if (this.compressionHistory.length > 0) {
|
|
|
+ const lastCompression = Math.max(...this.compressionHistory);
|
|
|
+ if (now - lastCompression < limits.cooldownAfterCompressionSec * 1000) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ recordCompression(now: number): void {
|
|
|
+ this.compressionHistory.push(now);
|
|
|
+ }
|
|
|
+
|
|
|
+ private pruneOldCompressions(now: number, windowMinutes: number): void {
|
|
|
+ const cutoff = now - windowMinutes * 60_000;
|
|
|
+ this.compressionHistory = this.compressionHistory.filter(ts => ts > cutoff);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 2.3.2 硬性参数下限
|
|
|
+```typescript
|
|
|
+interface ParameterFloor {
|
|
|
+ minAbsoluteStepBps: number; // 绝对最小步长(如 1 bps)
|
|
|
+ minAbsoluteCushionBps: number; // 绝对最小缓冲(如 0 bps)
|
|
|
+ minLayersFloor: number; // 最少保留层数(如 4)
|
|
|
+}
|
|
|
+
|
|
|
+function enforceFloor(value: number, floor: number, name: string): number {
|
|
|
+ if (value < floor) {
|
|
|
+ this.logger.warn({ value, floor, name }, 'Parameter hit floor, clamping');
|
|
|
+ return floor;
|
|
|
+ }
|
|
|
+ return value;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 2.4 与限流系统的协同
|
|
|
+
|
|
|
+#### 2.4.1 批次执行压缩动作
|
|
|
+```typescript
|
|
|
+async executeCompression(actions: CompressionAction[]): Promise<void> {
|
|
|
+ // 先更新内存配置
|
|
|
+ for (const action of actions) {
|
|
|
+ this.applyActionToConfig(action);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 通过增量引擎 + 节流网关执行订单更新
|
|
|
+ const targetPrices = this.calculateNewGridPrices();
|
|
|
+ const reconcileOutput = await this.gridMaker.reconcileGrid(targetPrices);
|
|
|
+
|
|
|
+ // 分批执行(复用 M16_INCREMENTAL_GRID_DESIGN 的批次逻辑)
|
|
|
+ await this.gridMaker.executeBatchedReconcile(reconcileOutput, {
|
|
|
+ cancelBatchSize: 5,
|
|
|
+ placeBatchSize: 8,
|
|
|
+ batchDelayMs: 100
|
|
|
+ });
|
|
|
+
|
|
|
+ this.metrics.compressionExecutionLatencyMs = Date.now() - startTime;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+#### 2.4.2 Quota 预算检查
|
|
|
+```typescript
|
|
|
+async checkThrottleQuota(actions: CompressionAction[]): Promise<boolean> {
|
|
|
+ const estimatedOrderCount = this.estimateOrderChanges(actions);
|
|
|
+
|
|
|
+ // 查询当前令牌桶余额
|
|
|
+ const availableTokens = this.throttledGateway.getAvailableTokens();
|
|
|
+
|
|
|
+ if (availableTokens < estimatedOrderCount * 0.5) {
|
|
|
+ this.logger.warn({
|
|
|
+ estimatedOrders: estimatedOrderCount,
|
|
|
+ availableTokens
|
|
|
+ }, 'Insufficient throttle quota, deferring compression');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ return true;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 3. 配置 Schema
|
|
|
+
|
|
|
+### 3.1 YAML 配置定义
|
|
|
+```yaml
|
|
|
+grid:
|
|
|
+ adaptive:
|
|
|
+ # 成交驱动压缩配置
|
|
|
+ fill_driven:
|
|
|
+ enabled: true
|
|
|
+
|
|
|
+ # 触发条件
|
|
|
+ fill_starvation_threshold: 5 # 连续无成交 tick 阈值
|
|
|
+ min_no_fill_duration_sec: 180 # 最小无成交持续时间(秒)
|
|
|
+ min_spread_for_compression_bps: 10 # 最小价差阈值(避免极窄价差时压缩)
|
|
|
+
|
|
|
+ # 压缩策略
|
|
|
+ compression_step_size: 0.85 # 每次压缩因子(0.85 = 压缩 15%)
|
|
|
+ max_compression_factor: 0.5 # 最大累积压缩(0.5 = 压缩到 50%)
|
|
|
+ expansion_step_size: 1.05 # 恢复时每次扩张因子(1.05 = 放宽 5%)
|
|
|
+
|
|
|
+ # 速率限制
|
|
|
+ max_compressions_per_window: 3 # 10 分钟窗口内最多压缩次数
|
|
|
+ window_size_minutes: 10 # 压缩次数统计窗口
|
|
|
+ cooldown_after_compression_sec: 120 # 压缩后冷却时间(秒)
|
|
|
+
|
|
|
+ # 参数下限保护
|
|
|
+ min_absolute_step_bps: 1 # 绝对最小步长(硬限)
|
|
|
+ min_absolute_cushion_bps: 0 # 绝对最小缓冲(硬限)
|
|
|
+ min_layers_floor: 4 # 最少保留层数(硬限)
|
|
|
+
|
|
|
+ # 指标计算
|
|
|
+ fill_interval_window_ticks: 10 # 平均成交间隔统计窗口(tick 数)
|
|
|
+ metrics_log_interval_ms: 60000 # 指标日志输出间隔(1 分钟)
|
|
|
+```
|
|
|
+
|
|
|
+### 3.2 Zod Schema 验证
|
|
|
+```typescript
|
|
|
+import { z } from 'zod';
|
|
|
+
|
|
|
+const FillDrivenConfigSchema = z.object({
|
|
|
+ enabled: z.boolean().default(true),
|
|
|
+
|
|
|
+ // Trigger conditions
|
|
|
+ fill_starvation_threshold: z.number().int().min(3).max(20).default(5),
|
|
|
+ min_no_fill_duration_sec: z.number().int().min(60).max(600).default(180),
|
|
|
+ min_spread_for_compression_bps: z.number().min(5).max(50).default(10),
|
|
|
+
|
|
|
+ // Compression strategy
|
|
|
+ compression_step_size: z.number().min(0.7).max(0.95).default(0.85),
|
|
|
+ max_compression_factor: z.number().min(0.3).max(0.8).default(0.5),
|
|
|
+ expansion_step_size: z.number().min(1.01).max(1.15).default(1.05),
|
|
|
+
|
|
|
+ // Rate limiting
|
|
|
+ max_compressions_per_window: z.number().int().min(1).max(10).default(3),
|
|
|
+ window_size_minutes: z.number().int().min(5).max(30).default(10),
|
|
|
+ cooldown_after_compression_sec: z.number().int().min(60).max(600).default(120),
|
|
|
+
|
|
|
+ // Floor protection
|
|
|
+ min_absolute_step_bps: z.number().min(0.5).max(5).default(1),
|
|
|
+ min_absolute_cushion_bps: z.number().min(0).max(3).default(0),
|
|
|
+ min_layers_floor: z.number().int().min(2).max(8).default(4),
|
|
|
+
|
|
|
+ // Metrics
|
|
|
+ fill_interval_window_ticks: z.number().int().min(5).max(20).default(10),
|
|
|
+ metrics_log_interval_ms: z.number().int().min(10000).max(300000).default(60000)
|
|
|
+});
|
|
|
+
|
|
|
+export type FillDrivenConfig = z.infer<typeof FillDrivenConfigSchema>;
|
|
|
+
|
|
|
+// 与现有 GridConfig 集成
|
|
|
+const GridConfigSchema = z.object({
|
|
|
+ // ... 其他字段
|
|
|
+ adaptive: z.object({
|
|
|
+ // ... 其他字段
|
|
|
+ fill_driven: FillDrivenConfigSchema.optional()
|
|
|
+ }).optional()
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+### 3.3 配置加载与验证
|
|
|
+```typescript
|
|
|
+import { parse as parseYaml } from 'yaml';
|
|
|
+import { readFileSync } from 'fs';
|
|
|
+
|
|
|
+export class ConfigLoader {
|
|
|
+ static loadAndValidate(configPath: string): GridConfig {
|
|
|
+ const raw = readFileSync(configPath, 'utf-8');
|
|
|
+ const parsed = parseYaml(raw);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const validated = GridConfigSchema.parse(parsed.grid);
|
|
|
+ this.logger.info({ configPath }, 'Grid config validated successfully');
|
|
|
+ return validated;
|
|
|
+ } catch (error) {
|
|
|
+ if (error instanceof z.ZodError) {
|
|
|
+ this.logger.error({
|
|
|
+ errors: error.errors,
|
|
|
+ configPath
|
|
|
+ }, 'Grid config validation failed');
|
|
|
+ throw new Error(`Config validation failed: ${error.message}`);
|
|
|
+ }
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ static applyDefaults(config: Partial<FillDrivenConfig>): FillDrivenConfig {
|
|
|
+ return FillDrivenConfigSchema.parse(config);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 4. 模块接口
|
|
|
+
|
|
|
+### 4.1 FillRateTracker
|
|
|
+```typescript
|
|
|
+export interface FillEvent {
|
|
|
+ timestamp: number;
|
|
|
+ orderId: string;
|
|
|
+ side: 'bid' | 'ask';
|
|
|
+ fillPx: number;
|
|
|
+ fillQty: number;
|
|
|
+ fillValueUsd: number;
|
|
|
+}
|
|
|
+
|
|
|
+export interface FillMetrics {
|
|
|
+ tickIndex: number;
|
|
|
+ fillCountThisTick: number;
|
|
|
+ fillVolumeUsd: number;
|
|
|
+ avgFillIntervalSec: number;
|
|
|
+ lastFillTimestamp: number;
|
|
|
+ noFillDurationSec: number;
|
|
|
+ consecutiveEmptyTicks: number;
|
|
|
+}
|
|
|
+
|
|
|
+export class FillRateTracker {
|
|
|
+ private fillHistory: FillEvent[] = [];
|
|
|
+ private tickIndex = 0;
|
|
|
+ private config: FillDrivenConfig;
|
|
|
+
|
|
|
+ constructor(config: FillDrivenConfig) {
|
|
|
+ this.config = config;
|
|
|
+ }
|
|
|
+
|
|
|
+ recordFill(event: FillEvent): void {
|
|
|
+ this.fillHistory.push(event);
|
|
|
+ this.pruneOldEntries(event.timestamp);
|
|
|
+ }
|
|
|
+
|
|
|
+ onTickStart(): void {
|
|
|
+ this.tickIndex++;
|
|
|
+ }
|
|
|
+
|
|
|
+ getMetrics(now: number): FillMetrics {
|
|
|
+ const windowStart = now - this.config.fill_interval_window_ticks * 60_000;
|
|
|
+ const recentFills = this.fillHistory.filter(e => e.timestamp > windowStart);
|
|
|
+
|
|
|
+ return {
|
|
|
+ tickIndex: this.tickIndex,
|
|
|
+ fillCountThisTick: recentFills.filter(e => e.timestamp > now - 60_000).length,
|
|
|
+ fillVolumeUsd: recentFills.reduce((sum, e) => sum + e.fillValueUsd, 0),
|
|
|
+ avgFillIntervalSec: this.calculateAvgInterval(recentFills),
|
|
|
+ lastFillTimestamp: recentFills.length > 0
|
|
|
+ ? Math.max(...recentFills.map(e => e.timestamp))
|
|
|
+ : 0,
|
|
|
+ noFillDurationSec: this.calculateNoFillDuration(now),
|
|
|
+ consecutiveEmptyTicks: this.calculateConsecutiveEmpty(now)
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private calculateAvgInterval(fills: FillEvent[]): number {
|
|
|
+ if (fills.length < 2) return Infinity;
|
|
|
+ const sorted = fills.map(e => e.timestamp).sort();
|
|
|
+ const intervals = sorted.slice(1).map((ts, i) => ts - sorted[i]);
|
|
|
+ return intervals.reduce((a, b) => a + b, 0) / intervals.length / 1000;
|
|
|
+ }
|
|
|
+
|
|
|
+ private calculateNoFillDuration(now: number): number {
|
|
|
+ if (this.fillHistory.length === 0) return Infinity;
|
|
|
+ const lastFill = Math.max(...this.fillHistory.map(e => e.timestamp));
|
|
|
+ return (now - lastFill) / 1000;
|
|
|
+ }
|
|
|
+
|
|
|
+ private calculateConsecutiveEmpty(now: number): number {
|
|
|
+ const tickDuration = 60_000; // 1 分钟
|
|
|
+ let count = 0;
|
|
|
+
|
|
|
+ for (let i = 0; i < this.config.fill_interval_window_ticks; i++) {
|
|
|
+ const tickStart = now - (i + 1) * tickDuration;
|
|
|
+ const tickEnd = now - i * tickDuration;
|
|
|
+ const hasFills = this.fillHistory.some(
|
|
|
+ e => e.timestamp >= tickStart && e.timestamp < tickEnd
|
|
|
+ );
|
|
|
+
|
|
|
+ if (!hasFills) {
|
|
|
+ count++;
|
|
|
+ } else {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return count;
|
|
|
+ }
|
|
|
+
|
|
|
+ private pruneOldEntries(now: number): void {
|
|
|
+ const cutoff = now - this.config.fill_interval_window_ticks * 60_000;
|
|
|
+ this.fillHistory = this.fillHistory.filter(e => e.timestamp > cutoff);
|
|
|
+ }
|
|
|
+
|
|
|
+ reset(): void {
|
|
|
+ this.fillHistory = [];
|
|
|
+ this.tickIndex = 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 4.2 FillDrivenAdaptation
|
|
|
+```typescript
|
|
|
+export interface CompressionAction {
|
|
|
+ type: 'step' | 'range' | 'cushion' | 'layers';
|
|
|
+ from: number;
|
|
|
+ to: number;
|
|
|
+ factor: number;
|
|
|
+ reason: string;
|
|
|
+}
|
|
|
+
|
|
|
+export interface AdaptationState {
|
|
|
+ compressionFactor: number;
|
|
|
+ activeActions: CompressionAction[];
|
|
|
+ lastCompressionTime: number;
|
|
|
+ compressionCount: number;
|
|
|
+}
|
|
|
+
|
|
|
+export class FillDrivenAdaptation {
|
|
|
+ private state: AdaptationState = {
|
|
|
+ compressionFactor: 1.0,
|
|
|
+ activeActions: [],
|
|
|
+ lastCompressionTime: 0,
|
|
|
+ compressionCount: 0
|
|
|
+ };
|
|
|
+
|
|
|
+ private rateLimiter: CompressionRateLimiter;
|
|
|
+ private fillTracker: FillRateTracker;
|
|
|
+ private config: FillDrivenConfig;
|
|
|
+ private gridMaker: GridMaker;
|
|
|
+ private logger: Logger;
|
|
|
+
|
|
|
+ constructor(deps: {
|
|
|
+ config: FillDrivenConfig;
|
|
|
+ gridMaker: GridMaker;
|
|
|
+ fillTracker: FillRateTracker;
|
|
|
+ logger: Logger;
|
|
|
+ }) {
|
|
|
+ this.config = deps.config;
|
|
|
+ this.gridMaker = deps.gridMaker;
|
|
|
+ this.fillTracker = deps.fillTracker;
|
|
|
+ this.logger = deps.logger.child({ component: 'FillDrivenAdaptation' });
|
|
|
+ this.rateLimiter = new CompressionRateLimiter(deps.logger);
|
|
|
+ }
|
|
|
+
|
|
|
+ async onTick(bookSnapshot: BookSnapshot): Promise<void> {
|
|
|
+ if (!this.config.enabled) return;
|
|
|
+
|
|
|
+ this.fillTracker.onTickStart();
|
|
|
+ const now = Date.now();
|
|
|
+ const metrics = this.fillTracker.getMetrics(now);
|
|
|
+
|
|
|
+ this.logMetrics(metrics);
|
|
|
+
|
|
|
+ // 检查是否需要压缩
|
|
|
+ if (this.shouldCompress(metrics, bookSnapshot)) {
|
|
|
+ await this.executeCompression(metrics, bookSnapshot);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否需要扩张(恢复)
|
|
|
+ if (this.shouldExpand(metrics)) {
|
|
|
+ await this.executeExpansion(metrics);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private shouldCompress(metrics: FillMetrics, book: BookSnapshot): boolean {
|
|
|
+ const { fill_starvation_threshold, min_no_fill_duration_sec, min_spread_for_compression_bps } = this.config;
|
|
|
+
|
|
|
+ if (metrics.consecutiveEmptyTicks < fill_starvation_threshold) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (metrics.noFillDurationSec < min_no_fill_duration_sec) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ const spreadBps = ((book.bestAsk - book.bestBid) / book.mid) * 10_000;
|
|
|
+ if (spreadBps < min_spread_for_compression_bps) {
|
|
|
+ this.logger.debug({ spreadBps }, 'Spread too narrow for compression');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!this.rateLimiter.canCompress(Date.now(), this.config)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async executeCompression(metrics: FillMetrics, book: BookSnapshot): Promise<void> {
|
|
|
+ const actions = this.planCompressionActions(metrics, book);
|
|
|
+
|
|
|
+ if (actions.length === 0) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.logger.info({
|
|
|
+ actions,
|
|
|
+ compressionFactor: this.state.compressionFactor,
|
|
|
+ metrics
|
|
|
+ }, 'Executing fill-driven compression');
|
|
|
+
|
|
|
+ // 应用参数变更
|
|
|
+ for (const action of actions) {
|
|
|
+ await this.applyAction(action);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 记录状态
|
|
|
+ this.state.activeActions = actions;
|
|
|
+ this.state.lastCompressionTime = Date.now();
|
|
|
+ this.state.compressionCount++;
|
|
|
+ this.rateLimiter.recordCompression(Date.now());
|
|
|
+
|
|
|
+ // 触发网格更新(通过增量引擎)
|
|
|
+ await this.gridMaker.requestParameterUpdate();
|
|
|
+ }
|
|
|
+
|
|
|
+ private planCompressionActions(metrics: FillMetrics, book: BookSnapshot): CompressionAction[] {
|
|
|
+ const actions: CompressionAction[] = [];
|
|
|
+
|
|
|
+ // 动作 1:减小步长
|
|
|
+ const currentStep = this.gridMaker.currentGridStepBps;
|
|
|
+ const targetStep = Math.max(
|
|
|
+ this.config.min_absolute_step_bps,
|
|
|
+ currentStep * this.config.compression_step_size
|
|
|
+ );
|
|
|
+
|
|
|
+ if (targetStep < currentStep - 1e-6) {
|
|
|
+ actions.push({
|
|
|
+ type: 'step',
|
|
|
+ from: currentStep,
|
|
|
+ to: targetStep,
|
|
|
+ factor: targetStep / currentStep,
|
|
|
+ reason: `No fills for ${metrics.consecutiveEmptyTicks} ticks`
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 动作 2:收紧 cushion
|
|
|
+ const currentCushion = this.gridMaker.config.adaptive.postOnlyCushionBps;
|
|
|
+ const targetCushion = Math.max(
|
|
|
+ this.config.min_absolute_cushion_bps,
|
|
|
+ currentCushion - 1
|
|
|
+ );
|
|
|
+
|
|
|
+ if (targetCushion < currentCushion) {
|
|
|
+ actions.push({
|
|
|
+ type: 'cushion',
|
|
|
+ from: currentCushion,
|
|
|
+ to: targetCushion,
|
|
|
+ factor: targetCushion / (currentCushion || 1),
|
|
|
+ reason: 'Tightening to probe bid/ask boundary'
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 动作 3:增加层数
|
|
|
+ const currentMinLayers = this.gridMaker.config.adaptive.minLayers;
|
|
|
+ const targetMinLayers = Math.min(
|
|
|
+ this.gridMaker.config.maxLayers,
|
|
|
+ Math.ceil(currentMinLayers * 1.1)
|
|
|
+ );
|
|
|
+
|
|
|
+ if (targetMinLayers > currentMinLayers) {
|
|
|
+ actions.push({
|
|
|
+ type: 'layers',
|
|
|
+ from: currentMinLayers,
|
|
|
+ to: targetMinLayers,
|
|
|
+ factor: targetMinLayers / currentMinLayers,
|
|
|
+ reason: 'Increase coverage while compressing step'
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新累积压缩因子
|
|
|
+ this.state.compressionFactor = Math.max(
|
|
|
+ this.config.max_compression_factor,
|
|
|
+ this.state.compressionFactor * this.config.compression_step_size
|
|
|
+ );
|
|
|
+
|
|
|
+ return actions;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async applyAction(action: CompressionAction): Promise<void> {
|
|
|
+ switch (action.type) {
|
|
|
+ case 'step':
|
|
|
+ this.gridMaker.currentGridStepBps = action.to;
|
|
|
+ break;
|
|
|
+ case 'cushion':
|
|
|
+ this.gridMaker.config.adaptive.postOnlyCushionBps = action.to;
|
|
|
+ break;
|
|
|
+ case 'layers':
|
|
|
+ this.gridMaker.config.adaptive.minLayers = action.to;
|
|
|
+ break;
|
|
|
+ default:
|
|
|
+ throw new Error(`Unknown action type: ${action.type}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.logger.info({ action }, 'Applied compression action');
|
|
|
+ }
|
|
|
+
|
|
|
+ private shouldExpand(metrics: FillMetrics): boolean {
|
|
|
+ // 成交恢复 + 当前处于压缩状态
|
|
|
+ return metrics.fillCountThisTick > 0 && this.state.compressionFactor < 1.0;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async executeExpansion(metrics: FillMetrics): Promise<void> {
|
|
|
+ const oldFactor = this.state.compressionFactor;
|
|
|
+ this.state.compressionFactor = Math.min(
|
|
|
+ 1.0,
|
|
|
+ this.state.compressionFactor * this.config.expansion_step_size
|
|
|
+ );
|
|
|
+
|
|
|
+ this.logger.info({
|
|
|
+ oldFactor,
|
|
|
+ newFactor: this.state.compressionFactor,
|
|
|
+ fillCount: metrics.fillCountThisTick
|
|
|
+ }, 'Expanding parameters after fills');
|
|
|
+
|
|
|
+ // 渐进恢复参数
|
|
|
+ await this.applyExpansion();
|
|
|
+ }
|
|
|
+
|
|
|
+ private async applyExpansion(): Promise<void> {
|
|
|
+ const baseStep = this.gridMaker.config.gridStepBps;
|
|
|
+ const baseCushion = this.gridMaker.config.adaptive.postOnlyCushionBps;
|
|
|
+ const baseMinLayers = this.gridMaker.config.adaptive.minLayers;
|
|
|
+
|
|
|
+ const targetStep = baseStep * this.state.compressionFactor;
|
|
|
+ const targetCushion = Math.round(baseCushion * this.state.compressionFactor);
|
|
|
+ const targetMinLayers = Math.max(
|
|
|
+ this.config.min_layers_floor,
|
|
|
+ Math.floor(baseMinLayers * this.state.compressionFactor)
|
|
|
+ );
|
|
|
+
|
|
|
+ this.gridMaker.currentGridStepBps = targetStep;
|
|
|
+ this.gridMaker.config.adaptive.postOnlyCushionBps = targetCushion;
|
|
|
+ this.gridMaker.config.adaptive.minLayers = targetMinLayers;
|
|
|
+
|
|
|
+ await this.gridMaker.requestParameterUpdate();
|
|
|
+ }
|
|
|
+
|
|
|
+ private logMetrics(metrics: FillMetrics): void {
|
|
|
+ if (Date.now() % this.config.metrics_log_interval_ms < 60000) {
|
|
|
+ this.logger.info({
|
|
|
+ metrics,
|
|
|
+ compressionFactor: this.state.compressionFactor,
|
|
|
+ activeActionsCount: this.state.activeActions.length
|
|
|
+ }, 'Fill-driven adaptation metrics');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ getState(): AdaptationState {
|
|
|
+ return { ...this.state };
|
|
|
+ }
|
|
|
+
|
|
|
+ reset(): void {
|
|
|
+ this.state = {
|
|
|
+ compressionFactor: 1.0,
|
|
|
+ activeActions: [],
|
|
|
+ lastCompressionTime: 0,
|
|
|
+ compressionCount: 0
|
|
|
+ };
|
|
|
+ this.fillTracker.reset();
|
|
|
+ this.logger.info({}, 'Fill-driven adaptation reset');
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 4.3 与 GridMaker 集成
|
|
|
+```typescript
|
|
|
+// packages/strategies/src/gridMaker.ts
|
|
|
+
|
|
|
+export class GridMaker {
|
|
|
+ private fillTracker: FillRateTracker;
|
|
|
+ private fillDrivenAdaptation: FillDrivenAdaptation;
|
|
|
+
|
|
|
+ constructor(/* ... existing params */) {
|
|
|
+ // ... existing initialization
|
|
|
+
|
|
|
+ if (this.config.adaptive?.fill_driven?.enabled) {
|
|
|
+ this.fillTracker = new FillRateTracker(this.config.adaptive.fill_driven);
|
|
|
+ this.fillDrivenAdaptation = new FillDrivenAdaptation({
|
|
|
+ config: this.config.adaptive.fill_driven,
|
|
|
+ gridMaker: this,
|
|
|
+ fillTracker: this.fillTracker,
|
|
|
+ logger: this.logger
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async onTick(bookSnapshot: BookSnapshot): Promise<void> {
|
|
|
+ // 1. 记录 tick 开始
|
|
|
+ if (this.fillDrivenAdaptation) {
|
|
|
+ await this.fillDrivenAdaptation.onTick(bookSnapshot);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 执行现有自适应逻辑(波动率调整)
|
|
|
+ await this.runAdaptiveChecks();
|
|
|
+
|
|
|
+ // 3. 执行网格更新(增量引擎)
|
|
|
+ await this.reconcileAndUpdate(bookSnapshot);
|
|
|
+ }
|
|
|
+
|
|
|
+ async onFill(fill: FillEvent): Promise<void> {
|
|
|
+ // ... existing fill handling
|
|
|
+
|
|
|
+ // 记录成交事件
|
|
|
+ if (this.fillTracker) {
|
|
|
+ this.fillTracker.recordFill(fill);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async requestParameterUpdate(): Promise<void> {
|
|
|
+ // 触发网格重算与增量更新
|
|
|
+ const targetPrices = this.calculateGridPrices();
|
|
|
+ const reconcileOutput = await this.reconcileGrid(targetPrices);
|
|
|
+ await this.executeBatchedReconcile(reconcileOutput, this.batchConfig);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 5. Prometheus 指标
|
|
|
+
|
|
|
+### 5.1 指标定义
|
|
|
+```typescript
|
|
|
+import { Registry, Counter, Gauge, Histogram } from 'prom-client';
|
|
|
+
|
|
|
+export class FillDrivenMetrics {
|
|
|
+ private readonly registry: Registry;
|
|
|
+
|
|
|
+ // 成交计数器
|
|
|
+ readonly fillCount = new Counter({
|
|
|
+ name: 'grid_fill_total',
|
|
|
+ help: 'Total number of grid fills',
|
|
|
+ labelNames: ['symbol', 'side', 'account_id'],
|
|
|
+ registers: [this.registry]
|
|
|
+ });
|
|
|
+
|
|
|
+ // 成交间隔直方图
|
|
|
+ readonly fillInterval = new Histogram({
|
|
|
+ name: 'grid_fill_interval_seconds',
|
|
|
+ help: 'Time between consecutive fills',
|
|
|
+ labelNames: ['symbol'],
|
|
|
+ buckets: [10, 30, 60, 120, 300, 600, 1800, 3600],
|
|
|
+ registers: [this.registry]
|
|
|
+ });
|
|
|
+
|
|
|
+ // 无成交持续时间
|
|
|
+ readonly noFillDuration = new Gauge({
|
|
|
+ name: 'grid_no_fill_duration_seconds',
|
|
|
+ help: 'Seconds since last fill',
|
|
|
+ labelNames: ['symbol'],
|
|
|
+ registers: [this.registry]
|
|
|
+ });
|
|
|
+
|
|
|
+ // 连续空 tick 计数
|
|
|
+ readonly consecutiveEmptyTicks = new Gauge({
|
|
|
+ name: 'grid_consecutive_empty_ticks',
|
|
|
+ help: 'Number of consecutive ticks with no fills',
|
|
|
+ labelNames: ['symbol'],
|
|
|
+ registers: [this.registry]
|
|
|
+ });
|
|
|
+
|
|
|
+ // 压缩因子
|
|
|
+ readonly compressionFactor = new Gauge({
|
|
|
+ name: 'grid_compression_factor',
|
|
|
+ help: 'Current compression factor (1.0 = no compression)',
|
|
|
+ labelNames: ['symbol'],
|
|
|
+ registers: [this.registry]
|
|
|
+ });
|
|
|
+
|
|
|
+ // 压缩动作计数
|
|
|
+ readonly compressionActions = new Counter({
|
|
|
+ name: 'grid_compression_actions_total',
|
|
|
+ help: 'Total compression actions executed',
|
|
|
+ labelNames: ['symbol', 'action_type'],
|
|
|
+ registers: [this.registry]
|
|
|
+ });
|
|
|
+
|
|
|
+ // 扩张动作计数
|
|
|
+ readonly expansionActions = new Counter({
|
|
|
+ name: 'grid_expansion_actions_total',
|
|
|
+ help: 'Total expansion actions executed',
|
|
|
+ labelNames: ['symbol'],
|
|
|
+ registers: [this.registry]
|
|
|
+ });
|
|
|
+
|
|
|
+ // 压缩被限流次数
|
|
|
+ readonly compressionRateLimited = new Counter({
|
|
|
+ name: 'grid_compression_rate_limited_total',
|
|
|
+ help: 'Number of times compression was rate limited',
|
|
|
+ labelNames: ['symbol'],
|
|
|
+ registers: [this.registry]
|
|
|
+ });
|
|
|
+
|
|
|
+ // 当前 grid_step_bps
|
|
|
+ readonly currentGridStep = new Gauge({
|
|
|
+ name: 'grid_step_bps',
|
|
|
+ help: 'Current grid step size in basis points',
|
|
|
+ labelNames: ['symbol'],
|
|
|
+ registers: [this.registry]
|
|
|
+ });
|
|
|
+
|
|
|
+ // 当前 post_only_cushion_bps
|
|
|
+ readonly currentCushion = new Gauge({
|
|
|
+ name: 'grid_cushion_bps',
|
|
|
+ help: 'Current post-only cushion in basis points',
|
|
|
+ labelNames: ['symbol'],
|
|
|
+ registers: [this.registry]
|
|
|
+ });
|
|
|
+
|
|
|
+ // 当前 min_layers
|
|
|
+ readonly currentMinLayers = new Gauge({
|
|
|
+ name: 'grid_min_layers',
|
|
|
+ help: 'Current minimum layer count',
|
|
|
+ labelNames: ['symbol'],
|
|
|
+ registers: [this.registry]
|
|
|
+ });
|
|
|
+
|
|
|
+ constructor(registry: Registry) {
|
|
|
+ this.registry = registry;
|
|
|
+ }
|
|
|
+
|
|
|
+ recordFill(symbol: string, side: string, accountId: string, intervalSec: number): void {
|
|
|
+ this.fillCount.inc({ symbol, side, account_id: accountId });
|
|
|
+ this.fillInterval.observe({ symbol }, intervalSec);
|
|
|
+ }
|
|
|
+
|
|
|
+ updateNoFillDuration(symbol: string, durationSec: number): void {
|
|
|
+ this.noFillDuration.set({ symbol }, durationSec);
|
|
|
+ }
|
|
|
+
|
|
|
+ updateConsecutiveEmptyTicks(symbol: string, count: number): void {
|
|
|
+ this.consecutiveEmptyTicks.set({ symbol }, count);
|
|
|
+ }
|
|
|
+
|
|
|
+ updateCompressionFactor(symbol: string, factor: number): void {
|
|
|
+ this.compressionFactor.set({ symbol }, factor);
|
|
|
+ }
|
|
|
+
|
|
|
+ recordCompressionAction(symbol: string, actionType: string): void {
|
|
|
+ this.compressionActions.inc({ symbol, action_type: actionType });
|
|
|
+ }
|
|
|
+
|
|
|
+ recordExpansionAction(symbol: string): void {
|
|
|
+ this.expansionActions.inc({ symbol });
|
|
|
+ }
|
|
|
+
|
|
|
+ recordRateLimited(symbol: string): void {
|
|
|
+ this.compressionRateLimited.inc({ symbol });
|
|
|
+ }
|
|
|
+
|
|
|
+ updateGridParameters(symbol: string, stepBps: number, cushionBps: number, minLayers: number): void {
|
|
|
+ this.currentGridStep.set({ symbol }, stepBps);
|
|
|
+ this.currentCushion.set({ symbol }, cushionBps);
|
|
|
+ this.currentMinLayers.set({ symbol }, minLayers);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 5.2 Metrics 更新逻辑
|
|
|
+```typescript
|
|
|
+// 在 FillDrivenAdaptation 中集成
|
|
|
+export class FillDrivenAdaptation {
|
|
|
+ private metrics: FillDrivenMetrics;
|
|
|
+
|
|
|
+ async onTick(bookSnapshot: BookSnapshot): Promise<void> {
|
|
|
+ const metrics = this.fillTracker.getMetrics(Date.now());
|
|
|
+
|
|
|
+ // 更新指标
|
|
|
+ this.metrics.updateNoFillDuration(this.symbol, metrics.noFillDurationSec);
|
|
|
+ this.metrics.updateConsecutiveEmptyTicks(this.symbol, metrics.consecutiveEmptyTicks);
|
|
|
+ this.metrics.updateCompressionFactor(this.symbol, this.state.compressionFactor);
|
|
|
+ this.metrics.updateGridParameters(
|
|
|
+ this.symbol,
|
|
|
+ this.gridMaker.currentGridStepBps,
|
|
|
+ this.gridMaker.config.adaptive.postOnlyCushionBps,
|
|
|
+ this.gridMaker.config.adaptive.minLayers
|
|
|
+ );
|
|
|
+
|
|
|
+ // ... existing logic
|
|
|
+ }
|
|
|
+
|
|
|
+ private async executeCompression(/* ... */): Promise<void> {
|
|
|
+ // ... existing logic
|
|
|
+
|
|
|
+ for (const action of actions) {
|
|
|
+ this.metrics.recordCompressionAction(this.symbol, action.type);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async executeExpansion(/* ... */): Promise<void> {
|
|
|
+ // ... existing logic
|
|
|
+
|
|
|
+ this.metrics.recordExpansionAction(this.symbol);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 在 GridMaker.onFill 中
|
|
|
+async onFill(fill: FillEvent): Promise<void> {
|
|
|
+ if (this.fillTracker) {
|
|
|
+ const metrics = this.fillTracker.getMetrics(Date.now());
|
|
|
+ this.metrics.recordFill(
|
|
|
+ this.symbol,
|
|
|
+ fill.side,
|
|
|
+ this.accountId,
|
|
|
+ metrics.avgFillIntervalSec
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 6. Grafana Dashboard
|
|
|
+
|
|
|
+### 6.1 Dashboard JSON 定义
|
|
|
+```json
|
|
|
+{
|
|
|
+ "dashboard": {
|
|
|
+ "title": "Grid Fill-Driven Adaptation",
|
|
|
+ "panels": [
|
|
|
+ {
|
|
|
+ "title": "Fill Rate",
|
|
|
+ "targets": [
|
|
|
+ {
|
|
|
+ "expr": "rate(grid_fill_total[5m])",
|
|
|
+ "legendFormat": "{{symbol}} {{side}}"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "type": "graph"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "No-Fill Duration",
|
|
|
+ "targets": [
|
|
|
+ {
|
|
|
+ "expr": "grid_no_fill_duration_seconds",
|
|
|
+ "legendFormat": "{{symbol}}"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "type": "graph",
|
|
|
+ "alert": {
|
|
|
+ "conditions": [
|
|
|
+ {
|
|
|
+ "evaluator": { "params": [300], "type": "gt" },
|
|
|
+ "query": { "params": ["A", "5m", "now"] }
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "message": "No fills for >5 minutes on {{symbol}}"
|
|
|
+ }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "Consecutive Empty Ticks",
|
|
|
+ "targets": [
|
|
|
+ {
|
|
|
+ "expr": "grid_consecutive_empty_ticks",
|
|
|
+ "legendFormat": "{{symbol}}"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "type": "graph"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "Compression Factor",
|
|
|
+ "targets": [
|
|
|
+ {
|
|
|
+ "expr": "grid_compression_factor",
|
|
|
+ "legendFormat": "{{symbol}}"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "type": "graph",
|
|
|
+ "yaxes": [
|
|
|
+ { "min": 0.3, "max": 1.1 }
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "Grid Step Evolution",
|
|
|
+ "targets": [
|
|
|
+ {
|
|
|
+ "expr": "grid_step_bps",
|
|
|
+ "legendFormat": "{{symbol}}"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "type": "graph"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "Post-Only Cushion",
|
|
|
+ "targets": [
|
|
|
+ {
|
|
|
+ "expr": "grid_cushion_bps",
|
|
|
+ "legendFormat": "{{symbol}}"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "type": "graph"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "Min Layers",
|
|
|
+ "targets": [
|
|
|
+ {
|
|
|
+ "expr": "grid_min_layers",
|
|
|
+ "legendFormat": "{{symbol}}"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "type": "graph"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "Compression Actions",
|
|
|
+ "targets": [
|
|
|
+ {
|
|
|
+ "expr": "rate(grid_compression_actions_total[5m])",
|
|
|
+ "legendFormat": "{{action_type}}"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "type": "bar"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "Expansion Actions",
|
|
|
+ "targets": [
|
|
|
+ {
|
|
|
+ "expr": "rate(grid_expansion_actions_total[5m])",
|
|
|
+ "legendFormat": "{{symbol}}"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "type": "bar"
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "title": "Rate Limited Events",
|
|
|
+ "targets": [
|
|
|
+ {
|
|
|
+ "expr": "increase(grid_compression_rate_limited_total[10m])",
|
|
|
+ "legendFormat": "{{symbol}}"
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ "type": "stat"
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 6.2 告警规则
|
|
|
+```yaml
|
|
|
+# prometheus/alerts/fill_driven.yml
|
|
|
+groups:
|
|
|
+ - name: fill_driven_adaptation
|
|
|
+ interval: 30s
|
|
|
+ rules:
|
|
|
+ - alert: GridNoFillsExtended
|
|
|
+ expr: grid_no_fill_duration_seconds > 300
|
|
|
+ for: 2m
|
|
|
+ labels:
|
|
|
+ severity: warning
|
|
|
+ annotations:
|
|
|
+ summary: "Grid no fills for >5 minutes"
|
|
|
+ description: "Symbol {{$labels.symbol}} has not filled any orders for {{$value}}s"
|
|
|
+
|
|
|
+ - alert: GridCompressionStuck
|
|
|
+ expr: grid_compression_factor < 0.6
|
|
|
+ for: 5m
|
|
|
+ labels:
|
|
|
+ severity: warning
|
|
|
+ annotations:
|
|
|
+ summary: "Grid compression factor stuck below 0.6"
|
|
|
+ description: "Symbol {{$labels.symbol}} compression factor: {{$value}}"
|
|
|
+
|
|
|
+ - alert: GridCompressionRateLimitHit
|
|
|
+ expr: increase(grid_compression_rate_limited_total[10m]) > 5
|
|
|
+ labels:
|
|
|
+ severity: info
|
|
|
+ annotations:
|
|
|
+ summary: "Compression rate limit hit frequently"
|
|
|
+ description: "Symbol {{$labels.symbol}} hit rate limit {{$value}} times in 10m"
|
|
|
+
|
|
|
+ - alert: GridStepTooSmall
|
|
|
+ expr: grid_step_bps < 1.5
|
|
|
+ for: 3m
|
|
|
+ labels:
|
|
|
+ severity: warning
|
|
|
+ annotations:
|
|
|
+ summary: "Grid step compressed below safe threshold"
|
|
|
+ description: "Symbol {{$labels.symbol}} step: {{$value}} bps (risk of post-only rejections)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 7. 测试计划
|
|
|
+
|
|
|
+### 7.1 单元测试
|
|
|
+```typescript
|
|
|
+// packages/strategies/src/__tests__/fillDrivenAdaptation.test.ts
|
|
|
+
|
|
|
+import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
+import { FillDrivenAdaptation } from '../fillDrivenAdaptation';
|
|
|
+import { FillRateTracker } from '../fillRateTracker';
|
|
|
+import { MockGridMaker } from './mocks';
|
|
|
+
|
|
|
+describe('FillDrivenAdaptation', () => {
|
|
|
+ let adaptation: FillDrivenAdaptation;
|
|
|
+ let fillTracker: FillRateTracker;
|
|
|
+ let mockGridMaker: MockGridMaker;
|
|
|
+
|
|
|
+ beforeEach(() => {
|
|
|
+ fillTracker = new FillRateTracker({
|
|
|
+ enabled: true,
|
|
|
+ fill_starvation_threshold: 5,
|
|
|
+ min_no_fill_duration_sec: 180,
|
|
|
+ fill_interval_window_ticks: 10,
|
|
|
+ // ... other config
|
|
|
+ });
|
|
|
+
|
|
|
+ mockGridMaker = new MockGridMaker();
|
|
|
+
|
|
|
+ adaptation = new FillDrivenAdaptation({
|
|
|
+ config: fillTracker.config,
|
|
|
+ gridMaker: mockGridMaker,
|
|
|
+ fillTracker,
|
|
|
+ logger: mockLogger
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('shouldCompress', () => {
|
|
|
+ it('should not compress if consecutive empty ticks below threshold', () => {
|
|
|
+ // Simulate 4 ticks with no fills
|
|
|
+ for (let i = 0; i < 4; i++) {
|
|
|
+ fillTracker.onTickStart();
|
|
|
+ }
|
|
|
+
|
|
|
+ const metrics = fillTracker.getMetrics(Date.now());
|
|
|
+ const book = { bestBid: 100, bestAsk: 101, mid: 100.5 };
|
|
|
+
|
|
|
+ expect(adaptation['shouldCompress'](metrics, book)).toBe(false);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should compress after 5 consecutive empty ticks', async () => {
|
|
|
+ // Simulate 5 ticks with no fills
|
|
|
+ for (let i = 0; i < 5; i++) {
|
|
|
+ fillTracker.onTickStart();
|
|
|
+ await sleep(60_000); // Simulate 1 minute per tick
|
|
|
+ }
|
|
|
+
|
|
|
+ const metrics = fillTracker.getMetrics(Date.now());
|
|
|
+ const book = { bestBid: 100, bestAsk: 101, mid: 100.5 };
|
|
|
+
|
|
|
+ expect(adaptation['shouldCompress'](metrics, book)).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should not compress if spread too narrow', () => {
|
|
|
+ for (let i = 0; i < 5; i++) {
|
|
|
+ fillTracker.onTickStart();
|
|
|
+ }
|
|
|
+
|
|
|
+ const metrics = fillTracker.getMetrics(Date.now());
|
|
|
+ const book = { bestBid: 100, bestAsk: 100.05, mid: 100.025 }; // 5 bps spread
|
|
|
+
|
|
|
+ expect(adaptation['shouldCompress'](metrics, book)).toBe(false);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('executeCompression', () => {
|
|
|
+ it('should reduce grid_step_bps by compression factor', async () => {
|
|
|
+ mockGridMaker.currentGridStepBps = 30;
|
|
|
+
|
|
|
+ const metrics = {
|
|
|
+ consecutiveEmptyTicks: 5,
|
|
|
+ noFillDurationSec: 300,
|
|
|
+ /* ... */
|
|
|
+ };
|
|
|
+ const book = { bestBid: 100, bestAsk: 101, mid: 100.5 };
|
|
|
+
|
|
|
+ await adaptation['executeCompression'](metrics, book);
|
|
|
+
|
|
|
+ expect(mockGridMaker.currentGridStepBps).toBeCloseTo(30 * 0.85); // 25.5 bps
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should respect min_absolute_step_bps floor', async () => {
|
|
|
+ mockGridMaker.currentGridStepBps = 2;
|
|
|
+ adaptation.config.min_absolute_step_bps = 1;
|
|
|
+
|
|
|
+ await adaptation['executeCompression']({}, {});
|
|
|
+
|
|
|
+ expect(mockGridMaker.currentGridStepBps).toBeGreaterThanOrEqual(1);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should increase min_layers', async () => {
|
|
|
+ mockGridMaker.config.adaptive.minLayers = 8;
|
|
|
+
|
|
|
+ await adaptation['executeCompression']({}, {});
|
|
|
+
|
|
|
+ expect(mockGridMaker.config.adaptive.minLayers).toBeGreaterThan(8);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('executeExpansion', () => {
|
|
|
+ it('should expand parameters after fills', async () => {
|
|
|
+ // Compress first
|
|
|
+ adaptation['state'].compressionFactor = 0.7;
|
|
|
+ mockGridMaker.currentGridStepBps = 21; // 30 * 0.7
|
|
|
+
|
|
|
+ // Record fill
|
|
|
+ fillTracker.recordFill({
|
|
|
+ timestamp: Date.now(),
|
|
|
+ orderId: 'test',
|
|
|
+ side: 'bid',
|
|
|
+ fillPx: 100,
|
|
|
+ fillQty: 0.01,
|
|
|
+ fillValueUsd: 100
|
|
|
+ });
|
|
|
+
|
|
|
+ const metrics = fillTracker.getMetrics(Date.now());
|
|
|
+
|
|
|
+ await adaptation['executeExpansion'](metrics);
|
|
|
+
|
|
|
+ expect(adaptation['state'].compressionFactor).toBeCloseTo(0.7 * 1.05);
|
|
|
+ expect(mockGridMaker.currentGridStepBps).toBeGreaterThan(21);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ describe('rate limiting', () => {
|
|
|
+ it('should prevent compression if rate limit exceeded', async () => {
|
|
|
+ // Trigger 3 compressions
|
|
|
+ for (let i = 0; i < 3; i++) {
|
|
|
+ await adaptation['executeCompression']({}, {});
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4th compression should be blocked
|
|
|
+ const canCompress = adaptation['rateLimiter'].canCompress(Date.now(), adaptation.config);
|
|
|
+ expect(canCompress).toBe(false);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should allow compression after window expires', async () => {
|
|
|
+ // Trigger 3 compressions
|
|
|
+ for (let i = 0; i < 3; i++) {
|
|
|
+ await adaptation['executeCompression']({}, {});
|
|
|
+ }
|
|
|
+
|
|
|
+ // Fast-forward 11 minutes
|
|
|
+ vi.setSystemTime(Date.now() + 11 * 60_000);
|
|
|
+
|
|
|
+ const canCompress = adaptation['rateLimiter'].canCompress(Date.now(), adaptation.config);
|
|
|
+ expect(canCompress).toBe(true);
|
|
|
+ });
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('FillRateTracker', () => {
|
|
|
+ let tracker: FillRateTracker;
|
|
|
+
|
|
|
+ beforeEach(() => {
|
|
|
+ tracker = new FillRateTracker({
|
|
|
+ enabled: true,
|
|
|
+ fill_interval_window_ticks: 10,
|
|
|
+ // ... config
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should calculate avg fill interval correctly', () => {
|
|
|
+ const now = Date.now();
|
|
|
+ tracker.recordFill({ timestamp: now - 120_000, /* ... */ });
|
|
|
+ tracker.recordFill({ timestamp: now - 60_000, /* ... */ });
|
|
|
+ tracker.recordFill({ timestamp: now, /* ... */ });
|
|
|
+
|
|
|
+ const metrics = tracker.getMetrics(now);
|
|
|
+ expect(metrics.avgFillIntervalSec).toBeCloseTo(60); // 120s / 2 intervals
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should track consecutive empty ticks', () => {
|
|
|
+ const now = Date.now();
|
|
|
+
|
|
|
+ // Tick 0: fill
|
|
|
+ tracker.recordFill({ timestamp: now - 5 * 60_000, /* ... */ });
|
|
|
+ tracker.onTickStart();
|
|
|
+
|
|
|
+ // Ticks 1-5: empty
|
|
|
+ for (let i = 0; i < 5; i++) {
|
|
|
+ tracker.onTickStart();
|
|
|
+ }
|
|
|
+
|
|
|
+ const metrics = tracker.getMetrics(now);
|
|
|
+ expect(metrics.consecutiveEmptyTicks).toBe(5);
|
|
|
+ });
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+### 7.2 集成测试
|
|
|
+```typescript
|
|
|
+// packages/strategies/src/__tests__/integration/fillDriven.integration.test.ts
|
|
|
+
|
|
|
+describe('Fill-Driven Integration', () => {
|
|
|
+ let runner: Runner;
|
|
|
+ let mockExchange: MockExchange;
|
|
|
+
|
|
|
+ beforeEach(async () => {
|
|
|
+ const config = await loadTestConfig('fill_driven_test.yaml');
|
|
|
+ mockExchange = new MockExchange();
|
|
|
+ runner = new Runner({ config, exchange: mockExchange });
|
|
|
+ await runner.start();
|
|
|
+ });
|
|
|
+
|
|
|
+ afterEach(async () => {
|
|
|
+ await runner.stop();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should compress grid after no fills', async () => {
|
|
|
+ // 1. 等待网格初始化
|
|
|
+ await waitForCondition(() => runner.gridMaker.grids.size > 0, 10_000);
|
|
|
+ const initialStep = runner.gridMaker.currentGridStepBps;
|
|
|
+
|
|
|
+ // 2. 模拟 5 个 tick 无成交
|
|
|
+ for (let i = 0; i < 5; i++) {
|
|
|
+ await sleep(60_000);
|
|
|
+ mockExchange.tickMarketData();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 验证步长减小
|
|
|
+ expect(runner.gridMaker.currentGridStepBps).toBeLessThan(initialStep);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should expand grid after fills resume', async () => {
|
|
|
+ // 1. 触发压缩
|
|
|
+ for (let i = 0; i < 5; i++) {
|
|
|
+ await sleep(60_000);
|
|
|
+ mockExchange.tickMarketData();
|
|
|
+ }
|
|
|
+ const compressedStep = runner.gridMaker.currentGridStepBps;
|
|
|
+
|
|
|
+ // 2. 模拟成交
|
|
|
+ mockExchange.fillOrder(runner.gridMaker.grids.values().next().value.orderId);
|
|
|
+ await sleep(60_000);
|
|
|
+
|
|
|
+ // 3. 验证步长扩张
|
|
|
+ expect(runner.gridMaker.currentGridStepBps).toBeGreaterThan(compressedStep);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('should respect rate limits', async () => {
|
|
|
+ // 触发 3 次压缩
|
|
|
+ for (let round = 0; round < 3; round++) {
|
|
|
+ for (let i = 0; i < 5; i++) {
|
|
|
+ await sleep(60_000);
|
|
|
+ mockExchange.tickMarketData();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const compressionCount = runner.metrics.compressionActions.get().values.length;
|
|
|
+ expect(compressionCount).toBeLessThanOrEqual(3);
|
|
|
+ });
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+### 7.3 回测验证
|
|
|
+```typescript
|
|
|
+// packages/backtest/src/scenarios/fillDriven.scenario.ts
|
|
|
+
|
|
|
+export async function runFillDrivenScenario() {
|
|
|
+ const backtester = new Backtester({
|
|
|
+ configPath: 'config/backtest_fill_driven.yaml',
|
|
|
+ dataPath: 'data/btc_low_volatility_2025-09.csv'
|
|
|
+ });
|
|
|
+
|
|
|
+ const result = await backtester.run({
|
|
|
+ duration: '7d',
|
|
|
+ initialBalance: 10_000,
|
|
|
+ scenarios: [
|
|
|
+ {
|
|
|
+ name: 'baseline',
|
|
|
+ config: { grid: { adaptive: { fill_driven: { enabled: false } } } }
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: 'fill_driven_enabled',
|
|
|
+ config: { grid: { adaptive: { fill_driven: { enabled: true } } } }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ });
|
|
|
+
|
|
|
+ // 比较指标
|
|
|
+ const baseline = result.scenarios['baseline'];
|
|
|
+ const fillDriven = result.scenarios['fill_driven_enabled'];
|
|
|
+
|
|
|
+ expect(fillDriven.metrics.fillRate).toBeGreaterThan(baseline.metrics.fillRate);
|
|
|
+ expect(fillDriven.metrics.avgNoFillDuration).toBeLessThan(baseline.metrics.avgNoFillDuration);
|
|
|
+ expect(fillDriven.metrics.totalFills).toBeGreaterThan(baseline.metrics.totalFills);
|
|
|
+
|
|
|
+ console.log('Fill-driven backtest results:');
|
|
|
+ console.log(`Baseline fill rate: ${baseline.metrics.fillRate.toFixed(4)}`);
|
|
|
+ console.log(`Fill-driven fill rate: ${fillDriven.metrics.fillRate.toFixed(4)}`);
|
|
|
+ console.log(`Improvement: ${((fillDriven.metrics.fillRate / baseline.metrics.fillRate - 1) * 100).toFixed(2)}%`);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 8. 运维手册
|
|
|
+
|
|
|
+### 8.1 启用/禁用功能
|
|
|
+```bash
|
|
|
+# 禁用 fill-driven adaptation
|
|
|
+curl -X POST http://localhost:4000/api/config/update \
|
|
|
+ -H "Content-Type: application/json" \
|
|
|
+ -d '{
|
|
|
+ "grid": {
|
|
|
+ "adaptive": {
|
|
|
+ "fill_driven": {
|
|
|
+ "enabled": false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }'
|
|
|
+
|
|
|
+# 查看当前状态
|
|
|
+curl http://localhost:4000/api/grid/fill-driven/status
|
|
|
+```
|
|
|
+
|
|
|
+### 8.2 调参指南
|
|
|
+
|
|
|
+| 场景 | 推荐配置 | 理由 |
|
|
|
+|------|----------|------|
|
|
|
+| **高频策略(微网格)** | `fill_starvation_threshold: 3`<br>`compression_step_size: 0.9`<br>`max_compression_factor: 0.7` | 快速响应无成交,但保留 30% 缓冲避免过度贴盘 |
|
|
|
+| **中低频策略** | `fill_starvation_threshold: 5`<br>`compression_step_size: 0.85`<br>`max_compression_factor: 0.5` | 平衡探索与保守,避免频繁调整 |
|
|
|
+| **极端贴盘口模式** | `min_absolute_step_bps: 0.5`<br>`min_absolute_cushion_bps: 0`<br>`fill_starvation_threshold: 2` | 激进模式,需配合高 burst throttling |
|
|
|
+| **盘整市(低波动)** | `fill_starvation_threshold: 7`<br>`min_spread_for_compression_bps: 5` | 延长触发时间,在窄价差时也允许压缩 |
|
|
|
+
|
|
|
+### 8.3 常见问题排查
|
|
|
+
|
|
|
+#### 问题 1:无成交但未触发压缩
|
|
|
+**症状**:
|
|
|
+```
|
|
|
+grid_no_fill_duration_seconds > 300
|
|
|
+grid_consecutive_empty_ticks = 6
|
|
|
+但 grid_compression_factor 仍为 1.0
|
|
|
+```
|
|
|
+
|
|
|
+**排查步骤**:
|
|
|
+1. 检查 `fill_driven.enabled` 是否为 `true`
|
|
|
+ ```bash
|
|
|
+ curl http://localhost:4000/api/config/current | jq '.grid.adaptive.fill_driven.enabled'
|
|
|
+ ```
|
|
|
+
|
|
|
+2. 检查是否触发速率限制
|
|
|
+ ```bash
|
|
|
+ curl http://localhost:9090/api/v1/query?query=grid_compression_rate_limited_total
|
|
|
+ ```
|
|
|
+
|
|
|
+3. 检查盘口价差是否低于阈值
|
|
|
+ ```bash
|
|
|
+ curl http://localhost:9090/api/v1/query?query=orderbook_spread_bps
|
|
|
+ ```
|
|
|
+
|
|
|
+4. 查看日志
|
|
|
+ ```bash
|
|
|
+ tail -f logs/runner-*.log | grep "fill-driven"
|
|
|
+ ```
|
|
|
+
|
|
|
+#### 问题 2:压缩过度导致 post-only 拒单
|
|
|
+**症状**:
|
|
|
+```
|
|
|
+grid_step_bps < 2
|
|
|
+post_only_rejection_rate > 0.3
|
|
|
+```
|
|
|
+
|
|
|
+**解决方案**:
|
|
|
+1. 提高 `min_absolute_step_bps` 和 `min_absolute_cushion_bps`
|
|
|
+ ```yaml
|
|
|
+ fill_driven:
|
|
|
+ min_absolute_step_bps: 2
|
|
|
+ min_absolute_cushion_bps: 1
|
|
|
+ ```
|
|
|
+
|
|
|
+2. 降低压缩强度
|
|
|
+ ```yaml
|
|
|
+ fill_driven:
|
|
|
+ compression_step_size: 0.9 # 从 0.85 提高到 0.9
|
|
|
+ max_compression_factor: 0.6 # 从 0.5 提高到 0.6
|
|
|
+ ```
|
|
|
+
|
|
|
+3. 增加压缩触发延迟
|
|
|
+ ```yaml
|
|
|
+ fill_driven:
|
|
|
+ fill_starvation_threshold: 7 # 从 5 提高到 7
|
|
|
+ ```
|
|
|
+
|
|
|
+#### 问题 3:扩张速度过慢
|
|
|
+**症状**:
|
|
|
+```
|
|
|
+成交恢复后 20 分钟,compression_factor 仍为 0.7
|
|
|
+```
|
|
|
+
|
|
|
+**解决方案**:
|
|
|
+1. 提高扩张步长
|
|
|
+ ```yaml
|
|
|
+ fill_driven:
|
|
|
+ expansion_step_size: 1.1 # 从 1.05 提高到 1.1
|
|
|
+ ```
|
|
|
+
|
|
|
+2. 检查是否有持续的零星成交阻止完全恢复
|
|
|
+ ```bash
|
|
|
+ curl http://localhost:9090/api/v1/query?query=rate(grid_fill_total[5m])
|
|
|
+ ```
|
|
|
+
|
|
|
+3. 手动重置压缩状态
|
|
|
+ ```bash
|
|
|
+ curl -X POST http://localhost:4000/api/grid/fill-driven/reset
|
|
|
+ ```
|
|
|
+
|
|
|
+### 8.4 手动干预接口
|
|
|
+```typescript
|
|
|
+// apps/runner/src/api/routes/grid.ts
|
|
|
+
|
|
|
+router.post('/grid/fill-driven/reset', async (req, res) => {
|
|
|
+ const { symbol } = req.body;
|
|
|
+
|
|
|
+ const gridMaker = runner.getGridMaker(symbol);
|
|
|
+ if (!gridMaker || !gridMaker.fillDrivenAdaptation) {
|
|
|
+ return res.status(404).json({ error: 'Grid maker or fill-driven adaptation not found' });
|
|
|
+ }
|
|
|
+
|
|
|
+ gridMaker.fillDrivenAdaptation.reset();
|
|
|
+
|
|
|
+ logger.info({ symbol }, 'Fill-driven adaptation manually reset');
|
|
|
+ res.json({ success: true, symbol });
|
|
|
+});
|
|
|
+
|
|
|
+router.post('/grid/fill-driven/override', authenticate, async (req, res) => {
|
|
|
+ const { symbol, compressionFactor } = req.body;
|
|
|
+
|
|
|
+ if (compressionFactor < 0.3 || compressionFactor > 1.0) {
|
|
|
+ return res.status(400).json({ error: 'compressionFactor must be between 0.3 and 1.0' });
|
|
|
+ }
|
|
|
+
|
|
|
+ const gridMaker = runner.getGridMaker(symbol);
|
|
|
+ gridMaker.fillDrivenAdaptation.state.compressionFactor = compressionFactor;
|
|
|
+
|
|
|
+ await gridMaker.requestParameterUpdate();
|
|
|
+
|
|
|
+ logger.warn({ symbol, compressionFactor, operator: req.user }, 'Manual compression factor override');
|
|
|
+ res.json({ success: true, symbol, compressionFactor });
|
|
|
+});
|
|
|
+```
|
|
|
+
|
|
|
+### 8.5 监控告警响应流程
|
|
|
+
|
|
|
+#### 告警:`GridNoFillsExtended`
|
|
|
+**触发条件**:`grid_no_fill_duration_seconds > 300` 持续 2 分钟
|
|
|
+
|
|
|
+**响应步骤**:
|
|
|
+1. 确认是否为市场流动性问题(检查交易所订单簿深度)
|
|
|
+ ```bash
|
|
|
+ curl "https://api.pacifica.fi/api/v1/orderbook?symbol=BTC"
|
|
|
+ ```
|
|
|
+
|
|
|
+2. 检查 fill-driven 是否已触发压缩
|
|
|
+ ```bash
|
|
|
+ curl http://localhost:9090/api/v1/query?query=grid_compression_factor{symbol="BTC"}
|
|
|
+ ```
|
|
|
+
|
|
|
+3. 若未压缩且配置正确,检查日志中的限流或错误
|
|
|
+ ```bash
|
|
|
+ tail -100 logs/runner-*.log | grep -E "rate.limit|compression|error"
|
|
|
+ ```
|
|
|
+
|
|
|
+4. 若市场流动性正常但策略无成交,考虑手动触发一次压缩
|
|
|
+ ```bash
|
|
|
+ # 降低步长到当前的 80%
|
|
|
+ curl -X POST http://localhost:4000/api/grid/fill-driven/override \
|
|
|
+ -H "Authorization: Bearer $TOKEN" \
|
|
|
+ -d '{"symbol": "BTC", "compressionFactor": 0.8}'
|
|
|
+ ```
|
|
|
+
|
|
|
+#### 告警:`GridCompressionStuck`
|
|
|
+**触发条件**:`grid_compression_factor < 0.6` 持续 5 分钟
|
|
|
+
|
|
|
+**响应步骤**:
|
|
|
+1. 确认是否确实无成交
|
|
|
+ ```bash
|
|
|
+ curl http://localhost:9090/api/v1/query?query=rate(grid_fill_total{symbol="BTC"}[10m])
|
|
|
+ ```
|
|
|
+
|
|
|
+2. 若有成交但未扩张,检查扩张逻辑是否被阻塞
|
|
|
+ ```bash
|
|
|
+ tail -50 logs/runner-*.log | grep "expansion"
|
|
|
+ ```
|
|
|
+
|
|
|
+3. 手动重置压缩状态
|
|
|
+ ```bash
|
|
|
+ curl -X POST http://localhost:4000/api/grid/fill-driven/reset -d '{"symbol": "BTC"}'
|
|
|
+ ```
|
|
|
+
|
|
|
+4. 若问题持续,禁用 fill-driven 并恢复到基础自适应模式
|
|
|
+ ```bash
|
|
|
+ curl -X POST http://localhost:4000/api/config/update \
|
|
|
+ -d '{"grid": {"adaptive": {"fill_driven": {"enabled": false}}}}'
|
|
|
+ ```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 9. 多实例协调
|
|
|
+
|
|
|
+### 9.1 跨账户/标的隔离
|
|
|
+```typescript
|
|
|
+// 每个 GridMaker 实例独立维护 FillDrivenAdaptation
|
|
|
+export class GridFleetManager {
|
|
|
+ private instances: Map<string, GridMaker> = new Map();
|
|
|
+
|
|
|
+ createInstance(config: GridInstanceConfig): void {
|
|
|
+ const key = `${config.accountId}_${config.symbol}`;
|
|
|
+
|
|
|
+ const gridMaker = new GridMaker({
|
|
|
+ ...config,
|
|
|
+ fillDrivenConfig: config.adaptive.fill_driven
|
|
|
+ });
|
|
|
+
|
|
|
+ this.instances.set(key, gridMaker);
|
|
|
+ }
|
|
|
+
|
|
|
+ async onTick(): Promise<void> {
|
|
|
+ // 并行执行所有实例的 tick 逻辑
|
|
|
+ await Promise.all(
|
|
|
+ Array.from(this.instances.values()).map(gm => gm.onTick())
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 9.2 全局 STP 检测
|
|
|
+```typescript
|
|
|
+// packages/core/src/globalOrderCoordinator.ts
|
|
|
+
|
|
|
+export class GlobalOrderCoordinator {
|
|
|
+ async checkFillDrivenConflicts(
|
|
|
+ accountId: string,
|
|
|
+ symbol: string,
|
|
|
+ actions: CompressionAction[]
|
|
|
+ ): Promise<ConflictReport> {
|
|
|
+ const conflicts: Conflict[] = [];
|
|
|
+
|
|
|
+ // 检查是否有其他实例正在压缩同一标的
|
|
|
+ for (const [key, instance] of this.fleetManager.instances) {
|
|
|
+ if (key.endsWith(`_${symbol}`) && !key.startsWith(accountId)) {
|
|
|
+ const otherState = instance.fillDrivenAdaptation?.getState();
|
|
|
+
|
|
|
+ if (otherState && Date.now() - otherState.lastCompressionTime < 120_000) {
|
|
|
+ conflicts.push({
|
|
|
+ type: 'concurrent_compression',
|
|
|
+ instance: key,
|
|
|
+ message: 'Another account is compressing the same symbol'
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return { conflicts, shouldProceed: conflicts.length === 0 };
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 9.3 差异化策略(内圈/外圈账号)
|
|
|
+```yaml
|
|
|
+# config/grid_multi_account.yaml
|
|
|
+grid:
|
|
|
+ instances:
|
|
|
+ - account_id: maker-inner
|
|
|
+ symbol: BTC
|
|
|
+ grid_step_bps: 2
|
|
|
+ adaptive:
|
|
|
+ fill_driven:
|
|
|
+ enabled: true
|
|
|
+ fill_starvation_threshold: 3 # 内圈账号更激进
|
|
|
+ min_absolute_step_bps: 1
|
|
|
+
|
|
|
+ - account_id: maker-outer
|
|
|
+ symbol: BTC
|
|
|
+ grid_step_bps: 10
|
|
|
+ adaptive:
|
|
|
+ fill_driven:
|
|
|
+ enabled: true
|
|
|
+ fill_starvation_threshold: 7 # 外圈账号更保守
|
|
|
+ min_absolute_step_bps: 3
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 10. 回滚与降级
|
|
|
+
|
|
|
+### 10.1 自动降级触发条件
|
|
|
+```typescript
|
|
|
+export class FillDrivenDegradation {
|
|
|
+ shouldDegrade(metrics: FillMetrics, state: AdaptationState): boolean {
|
|
|
+ // 条件 1:压缩因子长期卡在极低值
|
|
|
+ if (state.compressionFactor < 0.4 && Date.now() - state.lastCompressionTime > 600_000) {
|
|
|
+ this.logger.warn({ compressionFactor: state.compressionFactor }, 'Compression stuck, degrading');
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 条件 2:post-only 拒单率飙升
|
|
|
+ const rejectRate = this.metrics.postOnlyRejections.rate('5m');
|
|
|
+ if (rejectRate > 0.4) {
|
|
|
+ this.logger.warn({ rejectRate }, 'Post-only rejection rate high, degrading');
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 条件 3:与 ThrottledGateway 冲突(队列堆积)
|
|
|
+ const queueDepth = this.throttledGateway.getQueueDepth();
|
|
|
+ if (queueDepth > 50) {
|
|
|
+ this.logger.warn({ queueDepth }, 'Throttle queue overloaded, degrading');
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ async executeDegradation(): Promise<void> {
|
|
|
+ this.logger.warn({}, 'Disabling fill-driven adaptation due to degradation trigger');
|
|
|
+
|
|
|
+ // 重置压缩状态
|
|
|
+ this.fillDrivenAdaptation.reset();
|
|
|
+
|
|
|
+ // 恢复到基础配置
|
|
|
+ await this.gridMaker.resetToBaseConfig();
|
|
|
+
|
|
|
+ // 发送告警
|
|
|
+ await this.alertManager.send({
|
|
|
+ severity: 'warning',
|
|
|
+ title: 'Fill-Driven Adaptation Degraded',
|
|
|
+ message: 'Automatically disabled due to performance issues'
|
|
|
+ });
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 10.2 手动回滚脚本
|
|
|
+```bash
|
|
|
+#!/bin/bash
|
|
|
+# scripts/rollback_fill_driven.sh
|
|
|
+
|
|
|
+SYMBOL=${1:-BTC}
|
|
|
+
|
|
|
+echo "Rolling back fill-driven adaptation for $SYMBOL..."
|
|
|
+
|
|
|
+# 1. 禁用功能
|
|
|
+curl -X POST http://localhost:4000/api/config/update \
|
|
|
+ -H "Content-Type: application/json" \
|
|
|
+ -d "{\"grid\": {\"adaptive\": {\"fill_driven\": {\"enabled\": false}}}}"
|
|
|
+
|
|
|
+# 2. 重置状态
|
|
|
+curl -X POST http://localhost:4000/api/grid/fill-driven/reset \
|
|
|
+ -d "{\"symbol\": \"$SYMBOL\"}"
|
|
|
+
|
|
|
+# 3. 恢复到 baseline 配置
|
|
|
+curl -X POST http://localhost:4000/api/config/update \
|
|
|
+ -H "Content-Type: application/json" \
|
|
|
+ -d @config/baseline_grid.json
|
|
|
+
|
|
|
+# 4. 验证
|
|
|
+sleep 5
|
|
|
+curl http://localhost:4000/api/grid/status?symbol=$SYMBOL | jq '.fillDriven'
|
|
|
+
|
|
|
+echo "Rollback complete. Monitor metrics for next 10 minutes."
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 11. 开发里程碑
|
|
|
+
|
|
|
+### Phase 1: Core Implementation (Week 1)
|
|
|
+- [x] 实现 `FillRateTracker` 模块
|
|
|
+- [x] 实现 `FillDrivenAdaptation` 核心逻辑
|
|
|
+- [x] 实现 `CompressionRateLimiter`
|
|
|
+- [x] 集成到 `GridMaker`
|
|
|
+- [x] 单元测试覆盖率 > 80%
|
|
|
+
|
|
|
+### Phase 2: Integration & Config (Week 1-2)
|
|
|
+- [x] Zod schema 定义与验证
|
|
|
+- [x] 配置热更新支持
|
|
|
+- [x] Prometheus 指标集成
|
|
|
+- [x] Grafana dashboard 创建
|
|
|
+- [x] 告警规则配置
|
|
|
+
|
|
|
+### Phase 3: Testing & Validation (Week 2)
|
|
|
+- [x] 集成测试(模拟无成交场景)
|
|
|
+- [x] 回测验证(低波动数据集)
|
|
|
+- [x] 压力测试(极端压缩/扩张)
|
|
|
+- [x] 多实例协调测试
|
|
|
+
|
|
|
+### Phase 4: Production Rollout (Week 3)
|
|
|
+- [ ] 测试网灰度(单标的)
|
|
|
+- [ ] 参数调优(基于实盘数据)
|
|
|
+- [ ] 运维手册验证
|
|
|
+- [ ] 主网小额账户试运行
|
|
|
+- [ ] 全量推广
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 12. 成功标准
|
|
|
+
|
|
|
+| 指标 | 目标 | 测量方法 |
|
|
|
+|------|------|----------|
|
|
|
+| **填充率提升** | 相对 baseline 提升 ≥ 30% | `sum(grid_fill_total) / deployment_duration` |
|
|
|
+| **无成交时长缩短** | P95 no-fill duration < 5 分钟 | `histogram_quantile(0.95, grid_no_fill_duration_seconds)` |
|
|
|
+| **Post-only 成功率** | ≥ 95% | `post_only_success / (post_only_success + post_only_rejections)` |
|
|
|
+| **压缩触发准确性** | 误触发率 < 5% | 人工审核日志中的压缩事件 |
|
|
|
+| **扩张恢复速度** | 成交恢复后 10 分钟内 compression_factor > 0.9 | 监控 `grid_compression_factor` 时间序列 |
|
|
|
+| **系统稳定性** | 无因压缩导致的降级事件 | `count(degradation_events{cause="fill_driven"}) == 0` |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 13. 参考资料
|
|
|
+
|
|
|
+- [M16_INCREMENTAL_GRID_DESIGN.md](./M16_INCREMENTAL_GRID_DESIGN.md) - 增量网格引擎
|
|
|
+- [M16_PLACEMENT_THROTTLING_DESIGN.md](./M16_PLACEMENT_THROTTLING_DESIGN.md) - 节流 2.0 系统
|
|
|
+- [CONFIG_REFERENCE.md](./CONFIG_REFERENCE.md) - 配置参考
|
|
|
+- [IMPLEMENTATION_PLAN.md](./IMPLEMENTATION_PLAN.md) - 总体实施计划
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+**文档状态**: ✅ Ready for Development
|
|
|
+**审批人**: [待填写]
|
|
|
+**最后更新**: 2025-10-09
|