Spaces:
Running
Running
Update Quasar_axrvi_ranker.py
Browse files- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2739 |
"""
|
|
|
|
| 2740 |
if current_price <= 0:
|
| 2741 |
-
return 0
|
|
|
|
|
|
|
|
|
|
| 2742 |
|
| 2743 |
-
# ββ Layer 3: circuit breaker ββββββββββββββββββββββββββββββββββββββββββ
|
| 2744 |
dd = self._current_drawdown()
|
| 2745 |
now = time.time()
|
| 2746 |
|
| 2747 |
-
#
|
|
|
|
|
|
|
| 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]
|
| 2753 |
-
f"
|
| 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 |
-
|
| 2762 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2763 |
self._halt_until = None
|
| 2764 |
-
logger.info("[PortfolioRiskManager] β
Circuit breaker auto-resumed")
|
| 2765 |
|
| 2766 |
-
# Drawdown reduce
|
| 2767 |
-
dd_adj = 0.5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2768 |
|
| 2769 |
-
# ββ Layer 2: CVaR
|
|
|
|
|
|
|
|
|
|
| 2770 |
if cvar_05 < self.cfg.cvar_floor:
|
| 2771 |
-
|
| 2772 |
-
f"
|
|
|
|
| 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 |
-
|
| 2781 |
-
|
| 2782 |
-
|
| 2783 |
-
|
| 2784 |
-
|
| 2785 |
-
|
| 2786 |
-
|
| 2787 |
-
|
| 2788 |
-
|
| 2789 |
-
|
| 2790 |
-
|
| 2791 |
-
|
| 2792 |
-
|
| 2793 |
-
|
| 2794 |
-
|
| 2795 |
-
|
| 2796 |
-
|
| 2797 |
-
|
| 2798 |
-
|
| 2799 |
-
|
| 2800 |
-
|
| 2801 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2802 |
|
| 2803 |
reason = (
|
| 2804 |
f"f_kelly={f_kelly:.5f} sig={significance:.3f} "
|
| 2805 |
-
f"cvar_adj={cvar_adj:.3f} dd_adj=
|
| 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
|
| 3402 |
quantity, sizing_reason = self.portfolio_risk_mgr.compute_position_size(
|
| 3403 |
-
asset_id
|
| 3404 |
-
current_price
|
| 3405 |
-
value_estimate
|
| 3406 |
-
realized_vol
|
| 3407 |
-
significance
|
| 3408 |
-
cvar_05
|
|
|
|
| 3409 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3410 |
if quantity <= 0:
|
| 3411 |
-
|
| 3412 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 3419 |
-
#
|
|
|
|
|
|
|
| 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.
|
| 3606 |
-
|
| 3607 |
-
|
| 3608 |
-
|
| 3609 |
-
|
| 3610 |
-
|
| 3611 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 3748 |
continue
|
| 3749 |
|
| 3750 |
-
# Gate B β
|
| 3751 |
if snap.signal_confidence <= 0.0:
|
| 3752 |
-
logger.
|
| 3753 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3754 |
|
| 3755 |
-
# Gate C β significance threshold
|
| 3756 |
if sig_w < self.config.score_threshold:
|
| 3757 |
logger.info(
|
| 3758 |
-
f"[{asset_id}]
|
| 3759 |
-
f"< threshold={self.config.score_threshold:.3f}"
|
|
|
|
| 3760 |
)
|
| 3761 |
-
|
|
|
|
| 3762 |
|
| 3763 |
-
#
|
| 3764 |
-
buf
|
| 3765 |
-
vol_ratio
|
| 3766 |
-
jump_risk
|
| 3767 |
-
mart_dev_raw
|
| 3768 |
-
realized_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,
|
| 3779 |
)
|
| 3780 |
if not execute:
|
| 3781 |
-
logger.info(
|
| 3782 |
-
|
| 3783 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3784 |
logger.info(
|
| 3785 |
f"[{asset_id}] EXECUTE {snap.dominant_signal} | "
|
| 3786 |
-
f"
|
| 3787 |
-
f"
|
|
|
|
|
|
|
| 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,
|