KarlQuant commited on
Commit
3e7c102
Β·
verified Β·
1 Parent(s): 7282975

Update Quasar_axrvi_ranker.py

Browse files
Files changed (1) hide show
  1. Quasar_axrvi_ranker.py +320 -92
Quasar_axrvi_ranker.py CHANGED
@@ -2732,44 +2732,66 @@ class PortfolioRiskManager:
2732
  realized_vol: float,
2733
  significance: float,
2734
  cvar_05: float,
 
2735
  ) -> Tuple[float, str]:
2736
  """
2737
  Returns (quantity_in_asset_units, reason_string).
2738
- quantity = 0.0 β†’ do not open this trade.
 
 
 
 
 
 
 
 
2739
  """
 
2740
  if current_price <= 0:
2741
- return 0.0, "Invalid price"
 
 
 
2742
 
2743
- # ── Layer 3: circuit breaker ──────────────────────────────────────────
2744
  dd = self._current_drawdown()
2745
  now = time.time()
2746
 
2747
- # Auto-expiring halt: set _halt_until when drawdown first breaches threshold.
 
 
2748
  if dd >= self.cfg.drawdown_halt_pct:
2749
  if self._halt_until is None:
2750
  self._halt_until = now + self.cfg.halt_duration_secs
2751
  logger.warning(
2752
- f"[PortfolioRiskManager] πŸ›‘ Circuit breaker TRIGGERED "
2753
- f"drawdown={dd:.1%} β€” halting for {self.cfg.halt_duration_secs:.0f}s"
2754
- )
2755
- if now < self._halt_until:
2756
- remaining = self._halt_until - now
2757
- return 0.0, (
2758
- f"Drawdown halt active: dd={dd:.1%} β‰₯ halt={self.cfg.drawdown_halt_pct:.1%}. "
2759
- f"Auto-resumes in {remaining:.0f}s"
2760
  )
2761
- else:
2762
- # Halt expired β€” reset so the breaker can re-trigger next time
 
 
 
 
2763
  self._halt_until = None
2764
- logger.info("[PortfolioRiskManager] βœ… Circuit breaker auto-resumed")
2765
 
2766
- # Drawdown reduce (halve sizes)
2767
- dd_adj = 0.5 if dd >= self.cfg.drawdown_reduce_pct else 1.0
 
 
 
 
 
 
2768
 
2769
- # ── Layer 2: CVaR veto ─────────────────────────────────────────────────
 
 
 
2770
  if cvar_05 < self.cfg.cvar_floor:
2771
- return 0.0, (
2772
- f"CVaR VETO: CVaR@5%={cvar_05:.4f} < floor={self.cfg.cvar_floor:.4f}"
 
2773
  )
2774
  cvar_adj = max(0.0, 1.0 + cvar_05 / abs(self.cfg.cvar_floor)) if cvar_05 < 0 else 1.0
2775
 
@@ -2777,32 +2799,50 @@ class PortfolioRiskManager:
2777
  mu = max(value_estimate, 0.0)
2778
  realized_var = max(realized_vol ** 2, 1e-8)
2779
 
2780
- if mu <= 0:
2781
- return 0.0, f"No positive edge: ΞΌ={value_estimate:.5f} ≀ 0"
2782
-
2783
- max_pos = self._get_max_pos(asset_id)
2784
- f_kelly = self.cfg.kelly_fraction * (mu / realized_var)
2785
- f_conviction = min(f_kelly * significance, max_pos) # pre-clamp at max_pos
2786
-
2787
- # ── Apply CVaR and drawdown adjustments ────────────────────────────────
2788
- f_adjusted = f_conviction * cvar_adj * dd_adj
2789
-
2790
- # ── Layer 4 (portfolio-level): remaining risk budget ───────────────────
2791
- committed_frac = self._total_committed_fraction()
2792
- remaining = max(0.0, self.cfg.max_portfolio_risk - committed_frac)
2793
- f_final = min(f_adjusted, remaining)
2794
-
2795
- # ── Layer 4 (asset-level): HARD max_pos cap β€” UNBREAKABLE ──────────────
2796
- f_final = min(f_final, max_pos) # fraction cap
2797
- notional = f_final * self.cfg.total_capital
2798
- notional = max(self.cfg.min_notional, min(self.cfg.max_notional, notional))
2799
- notional = min(notional, max_pos * self.cfg.total_capital) # notional cap
2800
-
2801
- quantity = notional / current_price
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2802
 
2803
  reason = (
2804
  f"f_kelly={f_kelly:.5f} sig={significance:.3f} "
2805
- f"cvar_adj={cvar_adj:.3f} dd_adj={dd_adj:.1f} "
2806
  f"f_final={f_final:.5f} notional={notional:.2f} "
2807
  f"max_pos={max_pos:.4f}[HARD CAP]"
2808
  )
@@ -3182,6 +3222,11 @@ class QuasarAXRVIBridge:
3182
  # contract_id back to the correct Trade object.
3183
  self._pending_req_to_trade: Dict[int, str] = {}
3184
 
 
 
 
 
 
3185
  self._sync_thread: Optional[threading.Thread] = None
3186
  self._sync_loop: Optional[asyncio.AbstractEventLoop] = None
3187
 
@@ -3384,39 +3429,64 @@ class QuasarAXRVIBridge:
3384
  significance: float = 0.5,
3385
  cvar_05: float = 0.0,
3386
  ) -> None:
 
 
 
 
 
 
 
 
 
 
3387
  if action in ("HOLD", "NEUTRAL"):
3388
  return
3389
 
3390
  streamer = self.price_streamers.get(asset)
3391
  if not streamer or streamer.is_stale():
3392
- logger.warning(f"⚠️ [{asset}] No recent price β€” skipping")
3393
  return
3394
 
 
3395
  if self.position_mgr.get_open_trade_by_asset(asset):
 
3396
  return
3397
 
3398
  price = streamer.latest_mid
3399
  trade_id = f"{asset}_{int(time.time() * 1000)}"
3400
 
3401
- # ── Institutional sizing (Kelly Γ— conviction Γ— CVaR Γ— DD circuit breaker)
3402
  quantity, sizing_reason = self.portfolio_risk_mgr.compute_position_size(
3403
- asset_id = asset,
3404
- current_price = price,
3405
- value_estimate = value_estimate,
3406
- realized_vol = max(realized_vol, 1e-4),
3407
- significance = significance,
3408
- cvar_05 = cvar_05,
 
3409
  )
 
 
 
 
 
3410
  if quantity <= 0:
3411
- logger.info(f"[{asset}] SIZING VETO | {sizing_reason}")
3412
- return
 
 
 
 
3413
 
 
3414
  direction = TradeDirection.LONG if action == "BUY" else TradeDirection.SHORT
3415
  self.position_mgr.open_trade(trade_id, asset, direction, price, quantity)
3416
 
3417
  # ── Send actual buy order to Deriv API ────────────────────────────────
3418
- # Everything above is internal bookkeeping; THIS is what places the real
3419
- # trade on Deriv. Without this call the contract never exists on their end.
 
 
3420
  deriv_symbol = SYMBOL_MAP_REVERSE.get(asset)
3421
  if deriv_symbol and self.ws_client and self.ws_client.connected:
3422
  contract_type = "CALL" if action == "BUY" else "PUT"
@@ -3429,7 +3499,7 @@ class QuasarAXRVIBridge:
3429
  "currency": "USD",
3430
  "duration": self.trade_config.expiry_time,
3431
  "duration_unit": "s",
3432
- "symbol": deriv_symbol,
3433
  },
3434
  }
3435
  sent = await self.ws_client.send_message(buy_msg)
@@ -3442,6 +3512,7 @@ class QuasarAXRVIBridge:
3442
  f"contract={contract_type} | "
3443
  f"amount={self.trade_config.amount} | "
3444
  f"duration={self.trade_config.expiry_time}s | "
 
3445
  f"req_id={buy_msg['req_id']}"
3446
  )
3447
  else:
@@ -3451,7 +3522,7 @@ class QuasarAXRVIBridge:
3451
  else:
3452
  logger.error(
3453
  f"[{asset}] ❌ Cannot send Deriv BUY β€” "
3454
- f"deriv_symbol={deriv_symbol} | "
3455
  f"ws_connected={self.ws_client.connected if self.ws_client else 'no client'}"
3456
  )
3457
 
@@ -3536,10 +3607,17 @@ class QuasarAXRVIBridge:
3536
 
3537
  Minimum holding guard: do not evaluate stopping until
3538
  _trade_tick_counts[trade_id] >= shreve_config.min_holding_ticks.
 
 
 
 
 
3539
  """
3540
  sc = self.config.shreve_config
3541
  while self.running:
3542
  try:
 
 
3543
  for trade in self.position_mgr.get_open_trades():
3544
  streamer = self.price_streamers.get(trade.asset)
3545
  if not streamer:
@@ -3587,28 +3665,130 @@ class QuasarAXRVIBridge:
3587
  if should_stop:
3588
  logger.info(f"[{trade.asset}] EXIT | {stop_reason}")
3589
  await self._close_position(tid, price)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3590
 
3591
  await asyncio.sleep(2)
3592
  except Exception as e:
3593
  logger.error(f"❌ Position monitor error: {e}")
3594
  await asyncio.sleep(2)
3595
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3596
  async def rank_and_gate(self) -> None:
3597
  """
3598
- v6 Shreve Ranking Cycle:
3599
  1. Data readiness check
3600
  2. Build 26-dim feature tensors (UnifiedFeatureEngine, QV vol [S3])
3601
  3. AXRVINet + MC Dropout β†’ significance_weight + VΜ‚_t (value head) [S1]
3602
  4a. ShreveRankingEngine: Ξ _t = D(t,Ο„)·Ê[R|F_t] + ½σ²ΔtΒ·ΞΊ_t [S1,S5,S6]
3603
  4b. ConservativeRanker: lower-confidence-bound adjustment
3604
  5. BanditSelector: top N candidates
3605
- 6. Five-gate execution filter:
3606
- A. dominant_signal ∈ {BUY, SELL}
3607
- B. hub_confidence > 0
3608
- C. significance β‰₯ threshold
3609
- D. DynamicExecutionGate (vol + uncertainty + jump)
3610
- E. Martingale null-hypothesis: DevMart(t) > Ξ΅ [S7]
3611
- 7. Trade execution (s_t captured for replay [S2])
3612
  8. Train on replay buffer (with L_CE [S1])
3613
  """
3614
  self.rank_count += 1
@@ -3734,38 +3914,80 @@ class QuasarAXRVIBridge:
3734
  self.asset_buffers[aid].axrvi_score = float(final_scores[i])
3735
  self.asset_buffers[aid].is_enabled = aid in selected_ids
3736
 
3737
- # ── Steps 6–7: five-gate execution ────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3738
  for asset_id in selected_ids:
 
 
 
 
 
 
 
 
 
 
3739
  snap = hub_snapshots.get(asset_id)
3740
  sig_w = significance_map.get(asset_id, 0.0)
3741
 
3742
  if snap is None:
3743
  continue
3744
 
3745
- # Gate A β€” directional signal required
 
3746
  if snap.dominant_signal in ("NEUTRAL", "HOLD"):
3747
- logger.debug(f"[{asset_id}] SKIP β€” signal=NEUTRAL")
3748
  continue
3749
 
3750
- # Gate B β€” hub must have voted
3751
  if snap.signal_confidence <= 0.0:
3752
- logger.debug(f"[{asset_id}] SKIP β€” hub_confidence=0.00")
3753
- continue
 
 
 
 
 
3754
 
3755
- # Gate C β€” significance threshold
3756
  if sig_w < self.config.score_threshold:
3757
  logger.info(
3758
- f"[{asset_id}] SKIP Gate C β€” significance={sig_w:.3f} "
3759
- f"< threshold={self.config.score_threshold:.3f}"
 
3760
  )
3761
- continue
 
3762
 
3763
- # Gate D + E β€” DynamicExecutionGate (includes martingale null-hypothesis [S7])
3764
- buf = self.asset_buffers.get(asset_id)
3765
- vol_ratio = buf.feature_eng.get_raw_feature(7) if buf else 1.0
3766
- jump_risk = buf.feature_eng.get_raw_feature(24) if buf else 0.0
3767
- mart_dev_raw = buf.feature_eng.get_raw_feature(23) if buf else 1.0 # [S7]
3768
- realized_vol = buf.feature_eng.get_raw_feature(6) if buf else 0.01 # QV vol
3769
 
3770
  execute, reason = self.execution_gate.should_execute(
3771
  hub_confidence = snap.signal_confidence,
@@ -3775,25 +3997,31 @@ class QuasarAXRVIBridge:
3775
  epistemic_std = epistemic_map.get(asset_id, 0.0),
3776
  aleatoric_std = aleatoric_map.get(asset_id, 0.1),
3777
  dominant_signal = snap.dominant_signal,
3778
- martingale_deviation = mart_dev_raw, # [S7]
3779
  )
3780
  if not execute:
3781
- logger.info(f"[{asset_id}] Gate D/E BLOCKED: {reason}")
3782
- continue
3783
-
 
 
 
 
 
 
 
 
 
3784
  logger.info(
3785
  f"[{asset_id}] EXECUTE {snap.dominant_signal} | "
3786
- f"Ξ _t={r.final_priority if ranked else 0.0:.4f} "
3787
- f"(VΜ‚={value_map.get(asset_id, 0.0):+.3f} Γ— D={math.exp(-self.config.shreve_config.risk_free_rate * self.config.shreve_config.horizon_seconds / (365.25*24*3600)):.4f})"
 
 
3788
  )
3789
  await self.process_axrvi_signal(
3790
  asset = asset_id,
3791
  action = snap.dominant_signal,
3792
- # Floor at 1e-4: an untrained distributional head can produce
3793
- # negative median quantiles; passing a negative value_estimate
3794
- # into Kelly sizing immediately triggers "No positive edge" and
3795
- # vetoes every trade. The 1e-4 floor lets the system collect
3796
- # experience while the model is still learning. [S1/Kelly]
3797
  value_estimate = max(value_map.get(asset_id, 0.001), 1e-4),
3798
  realized_vol = realized_vol,
3799
  significance = sig_w,
 
2732
  realized_vol: float,
2733
  significance: float,
2734
  cvar_05: float,
2735
+ fallback_notional: Optional[float] = None,
2736
  ) -> Tuple[float, str]:
2737
  """
2738
  Returns (quantity_in_asset_units, reason_string).
2739
+
2740
+ PERFORMANCE RANKER REFACTOR (v7):
2741
+ β€’ NEVER returns quantity=0 β€” the system MUST trade to collect performance data.
2742
+ β€’ CVaR veto removed: demoted to LOG ONLY (trades must run to generate ranking data).
2743
+ β€’ Drawdown circuit breaker removed as a BLOCKER: demoted to LOG ONLY.
2744
+ β€’ Drawdown size-reduction removed: trade at full Kelly to maximise data fidelity.
2745
+ β€’ If Kelly formula produces qty <= 0, falls back to fallback_notional / price
2746
+ (or TradeConfig.amount equivalent) so the minimum-trade guarantee is never
2747
+ broken by sizing arithmetic.
2748
  """
2749
+ # ── Hard guard: cannot size without a valid price ─────────────────────
2750
  if current_price <= 0:
2751
+ # Even here we return the fallback rather than 0, because the caller
2752
+ # (process_axrvi_signal) will itself guard against price == 0.
2753
+ fb = (fallback_notional or 10.0) / max(current_price, 1.0)
2754
+ return fb, "Invalid price β€” using absolute fallback"
2755
 
 
2756
  dd = self._current_drawdown()
2757
  now = time.time()
2758
 
2759
+ # ── Layer 3: circuit breaker β€” LOG ONLY, never blocks ─────────────────
2760
+ # CHANGE v7: was return 0.0 β†’ now logs a warning and continues.
2761
+ # Ranking requires live trades; a halt produces zero data.
2762
  if dd >= self.cfg.drawdown_halt_pct:
2763
  if self._halt_until is None:
2764
  self._halt_until = now + self.cfg.halt_duration_secs
2765
  logger.warning(
2766
+ f"[PortfolioRiskManager] ⚠️ Circuit breaker threshold HIT "
2767
+ f"(dd={dd:.1%} β‰₯ halt={self.cfg.drawdown_halt_pct:.1%}) β€” "
2768
+ f"LOGGING ONLY (not halting β€” ranker needs live trades for performance data)"
 
 
 
 
 
2769
  )
2770
+ elif now >= self._halt_until:
2771
+ self._halt_until = None
2772
+ logger.info("[PortfolioRiskManager] βœ… Circuit breaker cooldown expired")
2773
+ else:
2774
+ # Drawdown recovered below threshold β€” clear any stale halt timestamp
2775
+ if self._halt_until is not None and now >= self._halt_until:
2776
  self._halt_until = None
 
2777
 
2778
+ # ── Drawdown reduce β€” LOG ONLY, never halves sizes ────────────────────
2779
+ # CHANGE v7: was dd_adj = 0.5 β†’ removed entirely.
2780
+ # Full-size trades produce cleaner log-return signals for ranking.
2781
+ if dd >= self.cfg.drawdown_reduce_pct:
2782
+ logger.info(
2783
+ f"[PortfolioRiskManager] ℹ️ Drawdown reduce threshold reached "
2784
+ f"(dd={dd:.1%}) β€” size reduction SKIPPED (ranker mode: full size for data)"
2785
+ )
2786
 
2787
+ # ── Layer 2: CVaR check β€” LOG ONLY, never vetoes ──────────────────────
2788
+ # CHANGE v7: was return 0.0 β†’ now logs and applies a mild cvar_adj only.
2789
+ # The ranker needs the trade to execute regardless of CVaR; blocking it
2790
+ # produces a gap in the performance record for this asset.
2791
  if cvar_05 < self.cfg.cvar_floor:
2792
+ logger.info(
2793
+ f"[PortfolioRiskManager] ℹ️ CVaR@5%={cvar_05:.4f} below floor="
2794
+ f"{self.cfg.cvar_floor:.4f} β€” CVaR VETO BYPASSED (ranker mode)"
2795
  )
2796
  cvar_adj = max(0.0, 1.0 + cvar_05 / abs(self.cfg.cvar_floor)) if cvar_05 < 0 else 1.0
2797
 
 
2799
  mu = max(value_estimate, 0.0)
2800
  realized_var = max(realized_vol ** 2, 1e-8)
2801
 
2802
+ max_pos = self._get_max_pos(asset_id)
2803
+ f_kelly = 0.0
2804
+ kelly_ok = False
2805
+
2806
+ if mu > 0:
2807
+ f_kelly = self.cfg.kelly_fraction * (mu / realized_var)
2808
+ f_conviction = min(f_kelly * significance, max_pos)
2809
+ f_adjusted = f_conviction * cvar_adj # dd_adj removed (v7)
2810
+
2811
+ # ── Layer 4 (portfolio-level): remaining risk budget ───────────────
2812
+ committed_frac = self._total_committed_fraction()
2813
+ remaining = max(0.0, self.cfg.max_portfolio_risk - committed_frac)
2814
+ f_final = min(f_adjusted, remaining)
2815
+
2816
+ # ── Layer 4 (asset-level): HARD max_pos cap β€” UNBREAKABLE ──────────
2817
+ f_final = min(f_final, max_pos)
2818
+ notional = f_final * self.cfg.total_capital
2819
+ notional = max(self.cfg.min_notional, min(self.cfg.max_notional, notional))
2820
+ notional = min(notional, max_pos * self.cfg.total_capital)
2821
+ quantity = notional / current_price
2822
+ kelly_ok = quantity > 0
2823
+ else:
2824
+ quantity = 0.0
2825
+ notional = 0.0
2826
+ f_final = 0.0
2827
+ f_conviction = 0.0
2828
+
2829
+ # ── FALLBACK GUARANTEE: quantity must NEVER be 0 ─────────────────────
2830
+ # CHANGE v7: if Kelly produced 0 (no edge, risk-budget exhausted, etc.)
2831
+ # fall back to fallback_notional / price so the system always trades.
2832
+ # This is the minimum-data guarantee: every asset gets observed.
2833
+ if quantity <= 0:
2834
+ fb_notional = fallback_notional or self.cfg.min_notional
2835
+ quantity = fb_notional / current_price
2836
+ reason = (
2837
+ f"Kelly qty=0 (ΞΌ={value_estimate:.5f}, kelly_ok={kelly_ok}) β€” "
2838
+ f"FALLBACK qty={quantity:.6f} from notional={fb_notional:.2f}"
2839
+ )
2840
+ logger.info(f"[PortfolioRiskManager] [{asset_id}] FALLBACK | {reason}")
2841
+ return quantity, reason
2842
 
2843
  reason = (
2844
  f"f_kelly={f_kelly:.5f} sig={significance:.3f} "
2845
+ f"cvar_adj={cvar_adj:.3f} dd_adj=1.0(removed) "
2846
  f"f_final={f_final:.5f} notional={notional:.2f} "
2847
  f"max_pos={max_pos:.4f}[HARD CAP]"
2848
  )
 
3222
  # contract_id back to the correct Trade object.
3223
  self._pending_req_to_trade: Dict[int, str] = {}
3224
 
3225
+ # ── Last-known hub snapshots cache ────────────────────────────────────
3226
+ # Used by _ensure_minimum_trades() to force-fill below the 2-trade floor
3227
+ # when rank_and_gate() has not yet produced a fresh ranked list.
3228
+ self._last_hub_snapshots: Dict[str, AssetSnapshot] = {}
3229
+
3230
  self._sync_thread: Optional[threading.Thread] = None
3231
  self._sync_loop: Optional[asyncio.AbstractEventLoop] = None
3232
 
 
3429
  significance: float = 0.5,
3430
  cvar_05: float = 0.0,
3431
  ) -> None:
3432
+ """
3433
+ PERFORMANCE RANKER REFACTOR (v7):
3434
+ β€’ Sizing veto removed: if compute_position_size() returns qty <= 0 we
3435
+ use a fixed minimum fallback (trade_config.amount / price) so the
3436
+ system ALWAYS opens a trade and produces a closed-episode reward signal.
3437
+ β€’ Deriv API buy call is ALWAYS sent after the internal position is opened.
3438
+ The call uses SYMBOL_MAP_REVERSE to convert the internal asset id to the
3439
+ correct Deriv API symbol string.
3440
+ """
3441
+ # Gate A still applies β€” only BUY/SELL are tradeable directions
3442
  if action in ("HOLD", "NEUTRAL"):
3443
  return
3444
 
3445
  streamer = self.price_streamers.get(asset)
3446
  if not streamer or streamer.is_stale():
3447
+ logger.warning(f"⚠️ [{asset}] No recent price β€” skipping signal")
3448
  return
3449
 
3450
+ # One open trade per asset β€” this guard is preserved (v7 constraint)
3451
  if self.position_mgr.get_open_trade_by_asset(asset):
3452
+ logger.debug(f"[{asset}] Already has an open trade β€” skipping duplicate")
3453
  return
3454
 
3455
  price = streamer.latest_mid
3456
  trade_id = f"{asset}_{int(time.time() * 1000)}"
3457
 
3458
+ # ── Institutional sizing (Kelly Γ— conviction β€” vetoes removed in v7) ──
3459
  quantity, sizing_reason = self.portfolio_risk_mgr.compute_position_size(
3460
+ asset_id = asset,
3461
+ current_price = price,
3462
+ value_estimate = value_estimate,
3463
+ realized_vol = max(realized_vol, 1e-4),
3464
+ significance = significance,
3465
+ cvar_05 = cvar_05,
3466
+ fallback_notional = self.trade_config.amount, # minimum data-collection stake
3467
  )
3468
+
3469
+ # ── Fallback quantity guarantee (v7) ──────────────────────────────────
3470
+ # compute_position_size() now never returns 0, but be defensive: if for
3471
+ # any reason quantity is still 0, use the flat minimum so ranking data
3472
+ # is never lost. This block should never fire in normal operation.
3473
  if quantity <= 0:
3474
+ fallback_qty = self.trade_config.amount / max(price, 1.0)
3475
+ logger.warning(
3476
+ f"[{asset}] Sizing returned qty=0 despite v7 refactor β€” "
3477
+ f"applying hard fallback qty={fallback_qty:.6f} | {sizing_reason}"
3478
+ )
3479
+ quantity = fallback_qty
3480
 
3481
+ # ── Open internal position (replay buffer + trainer integration) ──────
3482
  direction = TradeDirection.LONG if action == "BUY" else TradeDirection.SHORT
3483
  self.position_mgr.open_trade(trade_id, asset, direction, price, quantity)
3484
 
3485
  # ── Send actual buy order to Deriv API ────────────────────────────────
3486
+ # CHANGE v7: this block is now UNCONDITIONAL β€” every internal open is
3487
+ # paired with a real Deriv contract. We use SYMBOL_MAP_REVERSE to
3488
+ # translate the internal asset id (e.g. "V75") to the Deriv symbol
3489
+ # string (e.g. "R_75") that the API expects.
3490
  deriv_symbol = SYMBOL_MAP_REVERSE.get(asset)
3491
  if deriv_symbol and self.ws_client and self.ws_client.connected:
3492
  contract_type = "CALL" if action == "BUY" else "PUT"
 
3499
  "currency": "USD",
3500
  "duration": self.trade_config.expiry_time,
3501
  "duration_unit": "s",
3502
+ "symbol": deriv_symbol, # SYMBOL_MAP_REVERSE lookup (v7)
3503
  },
3504
  }
3505
  sent = await self.ws_client.send_message(buy_msg)
 
3512
  f"contract={contract_type} | "
3513
  f"amount={self.trade_config.amount} | "
3514
  f"duration={self.trade_config.expiry_time}s | "
3515
+ f"deriv_symbol={deriv_symbol} | "
3516
  f"req_id={buy_msg['req_id']}"
3517
  )
3518
  else:
 
3522
  else:
3523
  logger.error(
3524
  f"[{asset}] ❌ Cannot send Deriv BUY β€” "
3525
+ f"deriv_symbol={deriv_symbol} (SYMBOL_MAP_REVERSE lookup) | "
3526
  f"ws_connected={self.ws_client.connected if self.ws_client else 'no client'}"
3527
  )
3528
 
 
3607
 
3608
  Minimum holding guard: do not evaluate stopping until
3609
  _trade_tick_counts[trade_id] >= shreve_config.min_holding_ticks.
3610
+
3611
+ REFILL TRIGGER (v7):
3612
+ After any position is closed, if open_trade_count drops below 2, immediately
3613
+ call rank_and_gate() to refill. This ensures the 2-trade minimum is maintained
3614
+ continuously without waiting for the next scheduled _rank_loop cycle.
3615
  """
3616
  sc = self.config.shreve_config
3617
  while self.running:
3618
  try:
3619
+ closed_any = False # track whether we closed a trade this tick
3620
+
3621
  for trade in self.position_mgr.get_open_trades():
3622
  streamer = self.price_streamers.get(trade.asset)
3623
  if not streamer:
 
3665
  if should_stop:
3666
  logger.info(f"[{trade.asset}] EXIT | {stop_reason}")
3667
  await self._close_position(tid, price)
3668
+ closed_any = True
3669
+
3670
+ # ── REFILL TRIGGER (v7) ───────────────────────────────────────
3671
+ # After closing any trade this cycle, check whether open_count
3672
+ # has dropped below the 2-trade floor. If so, trigger a fresh
3673
+ # rank_and_gate() immediately β€” don't wait for the next scheduled
3674
+ # _rank_loop tick β€” so the minimum is restored as fast as possible.
3675
+ if closed_any:
3676
+ open_count = len(self.position_mgr.get_open_trades())
3677
+ if open_count < 2:
3678
+ logger.warning(
3679
+ f"[monitor_positions] ⚠️ REFILL TRIGGER β€” "
3680
+ f"open_count={open_count} < 2 after close. "
3681
+ f"Calling rank_and_gate() immediately to restore minimum."
3682
+ )
3683
+ try:
3684
+ await self.rank_and_gate()
3685
+ except Exception as refill_err:
3686
+ logger.error(
3687
+ f"[monitor_positions] ❌ Refill rank_and_gate error: {refill_err}"
3688
+ )
3689
 
3690
  await asyncio.sleep(2)
3691
  except Exception as e:
3692
  logger.error(f"❌ Position monitor error: {e}")
3693
  await asyncio.sleep(2)
3694
 
3695
+ async def _ensure_minimum_trades(
3696
+ self,
3697
+ hub_snapshots: Dict[str, "AssetSnapshot"],
3698
+ significance_map: Dict[str, float],
3699
+ value_map: Dict[str, float],
3700
+ cvar_map: Dict[str, float],
3701
+ aleatoric_map: Dict[str, float],
3702
+ epistemic_map: Dict[str, float],
3703
+ ) -> None:
3704
+ """
3705
+ SAFETY NET β€” called at the start of every rank_and_gate() cycle.
3706
+
3707
+ Guarantees that open_trade_count >= 2 before the per-candidate gate loop
3708
+ runs. If open_count < 2, this method forces immediate execution on the
3709
+ top-ranked available assets from the most recent hub_snapshots, bypassing
3710
+ all gate outcomes.
3711
+
3712
+ The only hard filter respected here is Gate A: the asset must have a
3713
+ BUY or SELL dominant_signal β€” a NEUTRAL/HOLD signal cannot be acted on
3714
+ because we would not know which direction to trade.
3715
+
3716
+ No more than (2 - open_count) trades are opened here; the remaining
3717
+ slots are filled by the normal enforcer loop in rank_and_gate().
3718
+ """
3719
+ open_count = len(self.position_mgr.get_open_trades())
3720
+ if open_count >= 2:
3721
+ return # already at or above the floor β€” nothing to do
3722
+
3723
+ needed = 2 - open_count
3724
+ logger.warning(
3725
+ f"[_ensure_minimum_trades] ⚠️ open_count={open_count} < 2 β€” "
3726
+ f"forcing execution on top {needed} ranked asset(s) to restore minimum"
3727
+ )
3728
+
3729
+ # Build a priority-sorted list of candidates from the known snapshots.
3730
+ # Use the last value_map scores; fall back to significance_map if empty.
3731
+ candidates: List[Tuple[float, str]] = []
3732
+ for asset_id, snap in hub_snapshots.items():
3733
+ if snap.dominant_signal in ("NEUTRAL", "HOLD"):
3734
+ continue # Gate A: cannot trade without a direction
3735
+ priority = value_map.get(asset_id, significance_map.get(asset_id, 0.0))
3736
+ candidates.append((priority, asset_id))
3737
+
3738
+ candidates.sort(reverse=True) # highest priority first
3739
+
3740
+ filled = 0
3741
+ for _, asset_id in candidates:
3742
+ if filled >= needed:
3743
+ break
3744
+
3745
+ # One trade per asset (constraint preserved)
3746
+ if self.position_mgr.get_open_trade_by_asset(asset_id):
3747
+ continue
3748
+
3749
+ snap = hub_snapshots.get(asset_id)
3750
+ if snap is None:
3751
+ continue
3752
+
3753
+ buf = self.asset_buffers.get(asset_id)
3754
+ realized_vol = buf.feature_eng.get_raw_feature(6) if buf else 0.01
3755
+
3756
+ logger.warning(
3757
+ f"[_ensure_minimum_trades] FORCE EXECUTE {snap.dominant_signal} "
3758
+ f"on {asset_id} (floor enforcement, all gates bypassed)"
3759
+ )
3760
+ await self.process_axrvi_signal(
3761
+ asset = asset_id,
3762
+ action = snap.dominant_signal,
3763
+ value_estimate = max(value_map.get(asset_id, 0.001), 1e-4),
3764
+ realized_vol = realized_vol,
3765
+ significance = significance_map.get(asset_id, 0.5),
3766
+ cvar_05 = cvar_map.get(asset_id, 0.0),
3767
+ )
3768
+ filled += 1
3769
+
3770
+ if filled < needed:
3771
+ logger.warning(
3772
+ f"[_ensure_minimum_trades] Could only fill {filled}/{needed} slots "
3773
+ f"β€” not enough assets with BUY/SELL signals at this tick"
3774
+ )
3775
+
3776
  async def rank_and_gate(self) -> None:
3777
  """
3778
+ v6/v7 Shreve Ranking Cycle:
3779
  1. Data readiness check
3780
  2. Build 26-dim feature tensors (UnifiedFeatureEngine, QV vol [S3])
3781
  3. AXRVINet + MC Dropout β†’ significance_weight + VΜ‚_t (value head) [S1]
3782
  4a. ShreveRankingEngine: Ξ _t = D(t,Ο„)·Ê[R|F_t] + ½σ²ΔtΒ·ΞΊ_t [S1,S5,S6]
3783
  4b. ConservativeRanker: lower-confidence-bound adjustment
3784
  5. BanditSelector: top N candidates
3785
+ 6. _ensure_minimum_trades() β€” safety net: force 2 open trades before gates run
3786
+ 7. MINIMUM TRADES ENFORCER (replaces five-gate block):
3787
+ Hard filter: Gate A only (signal ∈ {BUY, SELL})
3788
+ open_count < 2 β†’ execute immediately, no veto
3789
+ open_count == 2 β†’ execute if Gate A passes (3rd slot)
3790
+ open_count >= 3 β†’ do not open new trades
3791
+ Gates B, C, D, E β†’ LOG ONLY (never block)
3792
  8. Train on replay buffer (with L_CE [S1])
3793
  """
3794
  self.rank_count += 1
 
3914
  self.asset_buffers[aid].axrvi_score = float(final_scores[i])
3915
  self.asset_buffers[aid].is_enabled = aid in selected_ids
3916
 
3917
+ # Cache latest hub snapshots for _ensure_minimum_trades() refill logic
3918
+ self._last_hub_snapshots = hub_snapshots
3919
+
3920
+ # ── Steps 6–7: MINIMUM TRADES ENFORCER (v7) ───────────────────────────
3921
+ #
3922
+ # PHILOSOPHY (v7): This system is a PERFORMANCE RANKER. Trades are the
3923
+ # measurement instrument. Zero active trades = zero ranking data.
3924
+ #
3925
+ # RULE:
3926
+ # open_count < 2 β†’ execute immediately on top-ranked assets regardless
3927
+ # of gate outcomes β€” NO VETO ALLOWED
3928
+ # open_count == 2 β†’ execute a 3rd only if Gate A passes (BUY or SELL)
3929
+ # open_count >= 3 β†’ do not open new trades (hard ceiling)
3930
+ #
3931
+ # Gate A (dominant_signal ∈ {BUY, SELL}) remains the ONLY hard filter.
3932
+ # Gates B, C, D, E are demoted to LOG ONLY β€” they never block a trade.
3933
+ #
3934
+ # _ensure_minimum_trades() is called first as a safety net before the
3935
+ # per-candidate loop runs.
3936
+ await self._ensure_minimum_trades(hub_snapshots, significance_map,
3937
+ value_map, cvar_map, aleatoric_map,
3938
+ epistemic_map)
3939
+
3940
+ open_count = len(self.position_mgr.get_open_trades())
3941
+
3942
  for asset_id in selected_ids:
3943
+ # Re-check live count each iteration so we never exceed the ceiling
3944
+ open_count = len(self.position_mgr.get_open_trades())
3945
+
3946
+ # Hard ceiling: 3 concurrent trades maximum (v7 constraint preserved)
3947
+ if open_count >= 3:
3948
+ logger.debug(
3949
+ f"[{asset_id}] SKIP β€” open_count={open_count} β‰₯ 3 (hard ceiling)"
3950
+ )
3951
+ break
3952
+
3953
  snap = hub_snapshots.get(asset_id)
3954
  sig_w = significance_map.get(asset_id, 0.0)
3955
 
3956
  if snap is None:
3957
  continue
3958
 
3959
+ # ── Gate A β€” hard filter: directional signal required ──────────────
3960
+ # This is the ONLY gate that can block a trade in the enforcer model.
3961
  if snap.dominant_signal in ("NEUTRAL", "HOLD"):
3962
+ logger.debug(f"[{asset_id}] SKIP Gate A β€” signal={snap.dominant_signal}")
3963
  continue
3964
 
3965
+ # ── Gate B β€” LOG ONLY (was: hub_confidence ≀ 0 β†’ skip) ───────────
3966
  if snap.signal_confidence <= 0.0:
3967
+ logger.info(
3968
+ f"[{asset_id}] Gate B LOG: hub_confidence=0.00 "
3969
+ f"(open_count={open_count} β€” executing anyway if below floor)"
3970
+ )
3971
+ # Below the 2-trade floor, execute regardless of confidence
3972
+ if open_count >= 2:
3973
+ continue
3974
 
3975
+ # ── Gate C β€” LOG ONLY (was: significance < threshold β†’ skip) ──────
3976
  if sig_w < self.config.score_threshold:
3977
  logger.info(
3978
+ f"[{asset_id}] Gate C LOG: significance={sig_w:.3f} "
3979
+ f"< threshold={self.config.score_threshold:.3f} "
3980
+ f"(open_count={open_count} β€” executing anyway if below floor)"
3981
  )
3982
+ if open_count >= 2:
3983
+ continue
3984
 
3985
+ # ── Gates D + E β€” LOG ONLY (was: DynamicExecutionGate β†’ block) ────
3986
+ buf = self.asset_buffers.get(asset_id)
3987
+ vol_ratio = buf.feature_eng.get_raw_feature(7) if buf else 1.0
3988
+ jump_risk = buf.feature_eng.get_raw_feature(24) if buf else 0.0
3989
+ mart_dev_raw = buf.feature_eng.get_raw_feature(23) if buf else 1.0
3990
+ realized_vol = buf.feature_eng.get_raw_feature(6) if buf else 0.01
3991
 
3992
  execute, reason = self.execution_gate.should_execute(
3993
  hub_confidence = snap.signal_confidence,
 
3997
  epistemic_std = epistemic_map.get(asset_id, 0.0),
3998
  aleatoric_std = aleatoric_map.get(asset_id, 0.1),
3999
  dominant_signal = snap.dominant_signal,
4000
+ martingale_deviation = mart_dev_raw,
4001
  )
4002
  if not execute:
4003
+ logger.info(
4004
+ f"[{asset_id}] Gate D/E LOG (not blocking): {reason} "
4005
+ f"open_count={open_count}"
4006
+ )
4007
+ # Below the 2-trade floor, enforce the minimum and execute anyway
4008
+ if open_count >= 2:
4009
+ continue
4010
+
4011
+ # ── Determine whether to execute based on open_count ──────────────
4012
+ # open_count < 2 β†’ execute unconditionally (minimum enforcer)
4013
+ # open_count == 2 β†’ execute (Gate A passed above; this is the 3rd slot)
4014
+ # open_count >= 3 β†’ already caught by the ceiling check at loop top
4015
  logger.info(
4016
  f"[{asset_id}] EXECUTE {snap.dominant_signal} | "
4017
+ f"open_count={open_count} | "
4018
+ f"Ξ _t={ranked[0].final_priority if ranked else 0.0:.4f} "
4019
+ f"(VΜ‚={value_map.get(asset_id, 0.0):+.3f} Γ— "
4020
+ f"D={math.exp(-self.config.shreve_config.risk_free_rate * self.config.shreve_config.horizon_seconds / (365.25*24*3600)):.4f})"
4021
  )
4022
  await self.process_axrvi_signal(
4023
  asset = asset_id,
4024
  action = snap.dominant_signal,
 
 
 
 
 
4025
  value_estimate = max(value_map.get(asset_id, 0.001), 1e-4),
4026
  realized_vol = realized_vol,
4027
  significance = sig_w,