KarlQuant commited on
Commit
be43e8b
Β·
verified Β·
1 Parent(s): b5dd7a2

Upload Quasar_axrvi_ranker.py

Browse files
Files changed (1) hide show
  1. Quasar_axrvi_ranker.py +620 -75
Quasar_axrvi_ranker.py CHANGED
@@ -260,7 +260,7 @@ class ShreveConfig:
260
  # SECTION 1 β€” SHARED CONSTANTS
261
  # ══════════════════════════════════════════════════════════════════════════════════════
262
 
263
- DERIV_API_KEY = os.environ.get("DERIV_API_KEY", "1KJKxIJKR8LCyKB")
264
  DERIV_WS_URL = "wss://ws.binaryws.com/websockets/v3?app_id=1089"
265
 
266
  # Deriv API symbol β†’ AXRVI internal symbol
@@ -385,9 +385,10 @@ class TradeDirection(Enum):
385
 
386
 
387
  class PositionState(Enum):
388
- OPEN = "open"
389
- CLOSING = "closing"
390
- CLOSED = "closed"
 
391
 
392
 
393
  @dataclass
@@ -403,39 +404,145 @@ class PriceTick:
403
 
404
  @dataclass
405
  class Trade:
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  trade_id: str
407
- asset: str
408
  direction: TradeDirection
409
- entry_price: float
410
- entry_time: float
411
  quantity: float
412
- state: PositionState = PositionState.OPEN
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
  exit_price: Optional[float] = None
414
  exit_time: Optional[float] = None
415
- unrealized_pnl: float = 0.0
416
- realized_pnl: float = 0.0
 
 
 
 
417
  fees: float = 0.0
418
- contract_id: Optional[str] = None # Deriv contract ID β€” set on buy confirmation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
419
 
420
  def compute_unrealized_pnl(self, current_price: float) -> float:
 
 
 
 
 
 
 
421
  if self.direction == TradeDirection.LONG:
422
- return (current_price - self.entry_price) * self.quantity
423
- return (self.entry_price - current_price) * self.quantity
424
 
425
  def compute_unrealized_return(self, current_price: float) -> float:
 
 
 
 
426
  if self.direction == TradeDirection.LONG:
427
- return (current_price - self.entry_price) / self.entry_price
428
- return (self.entry_price - current_price) / self.entry_price
429
-
430
- def close(self, exit_price: float, exit_time: float, fees: float) -> None:
431
- self.exit_price = exit_price
432
- self.exit_time = exit_time
433
- self.fees = fees
434
- self.state = PositionState.CLOSED
435
- if self.direction == TradeDirection.LONG:
436
- self.realized_pnl = (exit_price - self.entry_price) * self.quantity - fees
437
- else:
438
- self.realized_pnl = (self.entry_price - exit_price) * self.quantity - fees
439
 
440
  @property
441
  def holding_duration(self) -> float:
@@ -2548,86 +2655,256 @@ class PriceStreamer:
2548
  # ══════════════════════════════════════════════════════════════════════════════════════
2549
 
2550
  class PositionManager:
2551
- """Thread-safe trade lifecycle manager with optional structured logger hooks (v3)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2552
 
2553
  def __init__(self, ranker_logger: Optional[object] = None):
2554
- self._open_trades: Dict[str, Trade] = {}
2555
- self._closed_trades: List[Trade] = []
2556
- self._lock = Lock()
2557
  self.total_realized_pnl: float = 0.0
2558
  self.total_fees: float = 0.0
2559
  self.trades_opened: int = 0
2560
  self.trades_closed: int = 0
2561
  self.ranker_logger = ranker_logger
2562
 
2563
- def open_trade(
 
 
2564
  self,
2565
  trade_id: str,
2566
  asset: str,
2567
  direction: TradeDirection,
2568
- entry_price: float,
2569
  quantity: float,
 
2570
  ) -> Trade:
 
 
 
 
 
 
 
 
 
 
 
 
 
2571
  with self._lock:
2572
- trade = Trade(
2573
- trade_id = trade_id,
2574
- asset = asset,
2575
- direction = direction,
2576
- entry_price = entry_price,
2577
- entry_time = time.time(),
2578
- quantity = quantity,
2579
- state = PositionState.OPEN,
2580
- )
2581
  self._open_trades[trade_id] = trade
2582
  self.trades_opened += 1
2583
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2584
  if self.ranker_logger:
2585
  self.ranker_logger.trade_open(
2586
- trade_id=trade_id, asset=asset,
2587
- direction=direction.value, price=entry_price, qty=quantity,
 
 
 
2588
  )
2589
 
2590
  logger.info(
2591
- f"βœ… [{asset}] TRADE OPENED | ID={trade_id} | "
2592
- f"Dir={direction.value.upper()} | Entry={entry_price:.4f}"
 
2593
  )
2594
  return trade
2595
 
2596
- def close_trade(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2597
  self,
2598
  trade_id: str,
2599
- exit_price: float,
2600
- fees: float = 0.0,
 
 
2601
  ) -> Optional[Trade]:
 
 
 
 
 
2602
  with self._lock:
2603
  if trade_id not in self._open_trades:
2604
- logger.warning(f"⚠️ Trade {trade_id} not found")
2605
  return None
2606
  trade = self._open_trades.pop(trade_id)
2607
- trade.close(exit_price, time.time(), fees)
 
 
 
 
 
 
 
 
 
 
 
2608
  self._closed_trades.append(trade)
2609
  self.trades_closed += 1
2610
  self.total_realized_pnl += trade.realized_pnl
2611
- self.total_fees += fees
2612
 
2613
  if self.ranker_logger:
2614
  return_pct = (
2615
- (trade.exit_price - trade.entry_price) / trade.entry_price
2616
- if trade.direction == TradeDirection.LONG
2617
- else (trade.entry_price - trade.exit_price) / trade.entry_price
 
 
2618
  )
2619
  self.ranker_logger.trade_close(
2620
- trade_id=trade_id, asset=trade.asset,
2621
- pnl=trade.realized_pnl, return_pct=return_pct,
 
 
2622
  )
2623
 
2624
- logger.info(f"πŸ”΄ [{trade.asset}] TRADE CLOSED | PnL={trade.realized_pnl:+.4f}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2625
  return trade
2626
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2627
  def get_open_trades(self) -> List[Trade]:
 
2628
  with self._lock:
2629
  return list(self._open_trades.values())
2630
 
 
 
 
 
 
 
 
 
2631
  def get_open_trade_by_asset(self, asset: str) -> Optional[Trade]:
2632
  with self._lock:
2633
  for t in self._open_trades.values():
@@ -2635,7 +2912,15 @@ class PositionManager:
2635
  return t
2636
  return None
2637
 
 
 
 
 
 
 
 
2638
  def get_unrealized_pnl(self, price_map: Dict[str, float]) -> float:
 
2639
  with self._lock:
2640
  return sum(
2641
  t.compute_unrealized_pnl(price_map.get(t.asset, t.entry_price))
@@ -2987,6 +3272,35 @@ class DerivWebSocketClient:
2987
  logger.error(f"❌ Subscription error for {symbol}: {e}")
2988
  return False
2989
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2990
  async def send_message(self, msg: dict) -> bool:
2991
  try:
2992
  msg["req_id"] = self._next_msg_id()
@@ -3365,36 +3679,267 @@ class QuasarAXRVIBridge:
3365
  # ── Deriv message handling ─────────────────────────────────────────────────────────
3366
 
3367
  def _on_deriv_message(self, msg: dict) -> None:
3368
- """Synchronous handler for Deriv WebSocket messages."""
 
 
 
 
 
 
 
 
 
3369
  try:
3370
  if "tick" in msg:
3371
  self._on_price_tick(msg["tick"])
 
3372
  elif "buy" in msg:
3373
- contract_id = msg["buy"].get("contract_id")
3374
- req_id = msg.get("req_id")
3375
- trade_id = self._pending_req_to_trade.pop(req_id, None)
3376
- if trade_id and contract_id:
3377
- # Bind the Deriv contract_id to the internal Trade object so
3378
- # _close_position can send a sell for early exit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3379
  with self.position_mgr._lock:
3380
- trade = self.position_mgr._open_trades.get(trade_id)
3381
- if trade:
3382
- trade.contract_id = str(contract_id)
3383
- logger.info(
3384
- f"βœ… Trade confirmed | trade_id={trade_id} | "
3385
- f"contract_id={contract_id}"
3386
- )
 
 
 
 
 
 
 
 
3387
  else:
3388
- logger.info(
3389
- f"βœ… Buy confirmation received | contract_id={contract_id} | "
3390
- f"req_id={req_id} (trade_id lookup: {trade_id})"
3391
  )
3392
- elif "error" in msg:
3393
- logger.error(f"⚠️ Deriv error: {msg['error']}")
3394
  if self.ranker_logger:
3395
  self.ranker_logger.connection_event("Deriv", "error", str(msg["error"]))
 
3396
  except Exception as e:
3397
  logger.error(f"❌ Deriv message handler error: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3398
 
3399
  def _on_price_tick(self, tick_data: dict) -> None:
3400
  try:
 
260
  # SECTION 1 β€” SHARED CONSTANTS
261
  # ══════════════════════════════════════════════════════════════════════════════════════
262
 
263
+ DERIV_API_KEY = os.environ.get("DERIV_API_KEY", "mXZY9NxhIqIJyrM")
264
  DERIV_WS_URL = "wss://ws.binaryws.com/websockets/v3?app_id=1089"
265
 
266
  # Deriv API symbol β†’ AXRVI internal symbol
 
385
 
386
 
387
  class PositionState(Enum):
388
+ PENDING = "pending" # buy sent to broker; awaiting buy confirmation
389
+ OPEN = "open" # broker confirmed; contract live
390
+ CLOSING = "closing" # sell sent to broker; awaiting terminal event
391
+ CLOSED = "closed" # broker reported terminal state (won/lost/sold/expired)
392
 
393
 
394
  @dataclass
 
404
 
405
  @dataclass
406
  class Trade:
407
+ """
408
+ Broker-backed trade record. All authoritative state comes from Deriv events.
409
+
410
+ Lifecycle:
411
+ PENDING β€” buy sent; no contract yet
412
+ OPEN β€” broker confirmed; contract_id bound
413
+ CLOSING β€” sell/early-exit sent; awaiting terminal event
414
+ CLOSED β€” broker reported final outcome (won / lost / sold / expired)
415
+
416
+ Fields marked [BROKER] must NOT be set locally;
417
+ they are written only from incoming Deriv WebSocket messages.
418
+ """
419
+ # ── Identity ─────────────────────────────────────────────────────────────
420
  trade_id: str
421
+ asset: str # internal AXRVI symbol (e.g. "V75")
422
  direction: TradeDirection
 
 
423
  quantity: float
424
+ entry_time: float # wall-clock time of buy send (for monitoring only)
425
+
426
+ # ── Broker primary key [BROKER] ───────────────────────────────────────────
427
+ contract_id: Optional[str] = None # Deriv contract_id from buy response
428
+ transaction_id: Optional[str] = None # Deriv transaction_id
429
+
430
+ # ── Broker contract details [BROKER] ─────────────────────────────────────
431
+ shortcode: Optional[str] = None # Deriv shortcode
432
+ broker_symbol: Optional[str] = None # Deriv API symbol (e.g. "R_75")
433
+ status: Optional[str] = None # open | won | lost | sold | expired
434
+
435
+ # ── Price fields [BROKER] ─────────────────────────────────────────────────
436
+ buy_price: Optional[float] = None # price paid for the contract
437
+ sell_price: Optional[float] = None # price received on close/sell
438
+ entry_tick: Optional[float] = None # spot price at contract open tick
439
+ current_spot: Optional[float] = None # latest spot (updated from poc stream)
440
+ profit: Optional[float] = None # broker-confirmed net P&L
441
+
442
+ # ── Internal tracking (non-authoritative) ────────────────────────────────
443
+ # entry_price is kept for optional-stopping log-return calc until broker
444
+ # provides the authoritative entry_tick. Updated from entry_tick on confirm.
445
+ entry_price: float = 0.0
446
  exit_price: Optional[float] = None
447
  exit_time: Optional[float] = None
448
+ state: PositionState = PositionState.PENDING
449
+
450
+ # These fields are retained as read-only mirrors of broker data and must
451
+ # NEVER be used as authoritative execution truth.
452
+ unrealized_pnl: float = 0.0 # live spot estimate only β€” NOT authoritative
453
+ realized_pnl: float = 0.0 # set from broker profit field on close
454
  fees: float = 0.0
455
+
456
+ # ── Broker state update (called from _on_deriv_message on buy confirm) ───
457
+
458
+ def confirm_open(
459
+ self,
460
+ contract_id: str,
461
+ buy_price: float,
462
+ entry_tick: float,
463
+ transaction_id: Optional[str] = None,
464
+ shortcode: Optional[str] = None,
465
+ broker_symbol: Optional[str] = None,
466
+ ) -> None:
467
+ """Transition PENDING β†’ OPEN from broker buy confirmation."""
468
+ self.contract_id = contract_id
469
+ self.buy_price = buy_price
470
+ self.entry_price = entry_tick # use broker tick as authoritative entry
471
+ self.entry_tick = entry_tick
472
+ self.current_spot = entry_tick
473
+ self.transaction_id = transaction_id
474
+ self.shortcode = shortcode
475
+ self.broker_symbol = broker_symbol
476
+ self.status = "open"
477
+ self.state = PositionState.OPEN
478
+
479
+ def update_from_poc(self, poc: dict) -> None:
480
+ """
481
+ Apply a proposal_open_contract (poc) stream update.
482
+ Handles both live tick updates and terminal states.
483
+ """
484
+ self.current_spot = float(poc.get("current_spot", self.current_spot or 0.0))
485
+ self.status = poc.get("status", self.status)
486
+
487
+ # Terminal event: populate close fields from broker data
488
+ if poc.get("is_expired") or poc.get("is_sold") or self.status in ("won", "lost", "sold", "expired"):
489
+ sell_price = poc.get("sell_price") or poc.get("bid_price")
490
+ if sell_price is not None:
491
+ self.sell_price = float(sell_price)
492
+ self.exit_price = self.sell_price
493
+ self.exit_time = time.time()
494
+ raw_profit = poc.get("profit")
495
+ if raw_profit is None:
496
+ raw_profit = poc.get("bid_price", 0.0)
497
+ self.profit = float(raw_profit) if raw_profit is not None else 0.0
498
+ self.realized_pnl = self.profit # mirror broker profit
499
+ self.state = PositionState.CLOSED
500
+
501
+ def close(self, exit_price: float, exit_time: float, fees: float) -> None:
502
+ """
503
+ Legacy compatibility close β€” only called when a broker terminal event
504
+ has been received and the broker profit is authoritative.
505
+ Uses broker sell_price if already set (preferred), otherwise
506
+ falls back to the passed exit_price for reward calc continuity.
507
+ """
508
+ if self.sell_price is not None:
509
+ self.exit_price = self.sell_price
510
+ else:
511
+ self.exit_price = exit_price
512
+ self.exit_time = exit_time
513
+ self.fees = fees
514
+ self.state = PositionState.CLOSED
515
+ # Do NOT recompute realized_pnl from local arithmetic; use broker profit
516
+ # if already available. If not (edge case), use local arithmetic as
517
+ # last-resort fallback so callers never get None.
518
+ if self.profit is not None:
519
+ self.realized_pnl = self.profit
520
+ else:
521
+ if self.direction == TradeDirection.LONG:
522
+ self.realized_pnl = (self.exit_price - self.entry_price) * self.quantity - fees
523
+ else:
524
+ self.realized_pnl = (self.entry_price - self.exit_price) * self.quantity - fees
525
 
526
  def compute_unrealized_pnl(self, current_price: float) -> float:
527
+ """
528
+ NON-AUTHORITATIVE spot estimate for optional-stopping G_t calc only.
529
+ NEVER used as execution truth.
530
+ """
531
+ ref = self.entry_tick if self.entry_tick else self.entry_price
532
+ if ref <= 0:
533
+ return 0.0
534
  if self.direction == TradeDirection.LONG:
535
+ return (current_price - ref) * self.quantity
536
+ return (ref - current_price) * self.quantity
537
 
538
  def compute_unrealized_return(self, current_price: float) -> float:
539
+ """Non-authoritative spot return β€” for G_t [S8] only."""
540
+ ref = self.entry_tick if self.entry_tick else self.entry_price
541
+ if ref <= 0:
542
+ return 0.0
543
  if self.direction == TradeDirection.LONG:
544
+ return (current_price - ref) / ref
545
+ return (ref - current_price) / ref
 
 
 
 
 
 
 
 
 
 
546
 
547
  @property
548
  def holding_duration(self) -> float:
 
2655
  # ══════════════════════════════════════════════════════════════════════════════════════
2656
 
2657
  class PositionManager:
2658
+ """
2659
+ Broker-backed trade registry.
2660
+
2661
+ Lifecycle contract:
2662
+ register_pending_buy() β€” buy sent to broker; trade enters PENDING
2663
+ confirm_buy() β€” broker buy confirmation received; trade enters OPEN
2664
+ mark_closing() β€” sell sent to broker; trade enters CLOSING
2665
+ close_trade_from_broker() β€” broker terminal event; trade enters CLOSED
2666
+
2667
+ NO local PnL computation is authoritative.
2668
+ NO state transitions happen without a broker event.
2669
+
2670
+ The legacy close_trade() method is retained as a thin wrapper around
2671
+ close_trade_from_broker() for backward compatibility with callers that
2672
+ pass an exit_price (e.g. checkpoint restore). It does NOT simulate fills.
2673
+ """
2674
 
2675
  def __init__(self, ranker_logger: Optional[object] = None):
2676
+ self._open_trades: Dict[str, Trade] = {} # contract_id NOT yet known (PENDING)
2677
+ self._closed_trades: List[Trade] = []
2678
+ self._lock = Lock()
2679
  self.total_realized_pnl: float = 0.0
2680
  self.total_fees: float = 0.0
2681
  self.trades_opened: int = 0
2682
  self.trades_closed: int = 0
2683
  self.ranker_logger = ranker_logger
2684
 
2685
+ # ── Phase 1: send buy ─────────────────────────────────────────────────────
2686
+
2687
+ def register_pending_buy(
2688
  self,
2689
  trade_id: str,
2690
  asset: str,
2691
  direction: TradeDirection,
 
2692
  quantity: float,
2693
+ broker_symbol: Optional[str] = None,
2694
  ) -> Trade:
2695
+ """
2696
+ Create a PENDING trade immediately after the buy message is sent.
2697
+ No price, no PnL, no fill β€” contract_id is unknown until broker confirms.
2698
+ """
2699
+ trade = Trade(
2700
+ trade_id = trade_id,
2701
+ asset = asset,
2702
+ direction = direction,
2703
+ quantity = quantity,
2704
+ entry_time = time.time(),
2705
+ broker_symbol = broker_symbol,
2706
+ state = PositionState.PENDING,
2707
+ )
2708
  with self._lock:
 
 
 
 
 
 
 
 
 
2709
  self._open_trades[trade_id] = trade
2710
  self.trades_opened += 1
2711
 
2712
+ logger.info(
2713
+ f"⏳ [{asset}] BUY SENT | trade_id={trade_id} | "
2714
+ f"dir={direction.value.upper()} | awaiting broker confirmation"
2715
+ )
2716
+ return trade
2717
+
2718
+ # ── Phase 2: broker buy confirmation ──────────────────────────────────────
2719
+
2720
+ def confirm_buy(
2721
+ self,
2722
+ trade_id: str,
2723
+ contract_id: str,
2724
+ buy_price: float,
2725
+ entry_tick: float,
2726
+ transaction_id: Optional[str] = None,
2727
+ shortcode: Optional[str] = None,
2728
+ broker_symbol: Optional[str] = None,
2729
+ ) -> Optional[Trade]:
2730
+ """
2731
+ Called from _on_deriv_message when a 'buy' response arrives.
2732
+ Transitions trade PENDING β†’ OPEN and binds all broker contract details.
2733
+ """
2734
+ with self._lock:
2735
+ trade = self._open_trades.get(trade_id)
2736
+ if trade is None:
2737
+ logger.warning(
2738
+ f"[PositionManager.confirm_buy] trade_id={trade_id} not found"
2739
+ )
2740
+ return None
2741
+ trade.confirm_open(
2742
+ contract_id = contract_id,
2743
+ buy_price = buy_price,
2744
+ entry_tick = entry_tick,
2745
+ transaction_id = transaction_id,
2746
+ shortcode = shortcode,
2747
+ broker_symbol = broker_symbol or trade.broker_symbol,
2748
+ )
2749
+
2750
  if self.ranker_logger:
2751
  self.ranker_logger.trade_open(
2752
+ trade_id = trade_id,
2753
+ asset = trade.asset,
2754
+ direction = trade.direction.value,
2755
+ price = entry_tick,
2756
+ qty = trade.quantity,
2757
  )
2758
 
2759
  logger.info(
2760
+ f"βœ… [{trade.asset}] TRADE OPENED | trade_id={trade_id} | "
2761
+ f"contract_id={contract_id} | entry_tick={entry_tick:.4f} | "
2762
+ f"buy_price={buy_price:.4f}"
2763
  )
2764
  return trade
2765
 
2766
+ # ── Phase 3: send sell ────────────────────────────────────────────────────
2767
+
2768
+ def mark_closing(self, trade_id: str) -> None:
2769
+ """
2770
+ Mark a trade as CLOSING after a sell request is sent.
2771
+ Actual close happens in close_trade_from_broker() when broker confirms.
2772
+ """
2773
+ with self._lock:
2774
+ trade = self._open_trades.get(trade_id)
2775
+ if trade:
2776
+ trade.state = PositionState.CLOSING
2777
+ logger.info(f"[{trade_id}] ⏳ SELL SENT β€” awaiting broker terminal event")
2778
+
2779
+ # ── Phase 4: broker terminal event ────────────────────────────────────────
2780
+
2781
+ def close_trade_from_broker(
2782
  self,
2783
  trade_id: str,
2784
+ status: str,
2785
+ profit: float,
2786
+ sell_price: Optional[float] = None,
2787
+ exit_tick: Optional[float] = None,
2788
  ) -> Optional[Trade]:
2789
+ """
2790
+ Called from _on_deriv_message when proposal_open_contract reports
2791
+ a terminal state (is_expired, is_sold, status in won/lost/sold/expired).
2792
+ Authoritative close β€” profit comes directly from broker.
2793
+ """
2794
  with self._lock:
2795
  if trade_id not in self._open_trades:
 
2796
  return None
2797
  trade = self._open_trades.pop(trade_id)
2798
+
2799
+ exit_price = sell_price or exit_tick or (trade.current_spot or 0.0)
2800
+ trade.profit = profit
2801
+ trade.realized_pnl = profit # authoritative broker P&L
2802
+ trade.sell_price = sell_price
2803
+ trade.exit_price = exit_price
2804
+ trade.exit_time = time.time()
2805
+ trade.status = status
2806
+ trade.state = PositionState.CLOSED
2807
+ trade.fees = 0.0 # fees already reflected in broker profit
2808
+
2809
+ with self._lock:
2810
  self._closed_trades.append(trade)
2811
  self.trades_closed += 1
2812
  self.total_realized_pnl += trade.realized_pnl
 
2813
 
2814
  if self.ranker_logger:
2815
  return_pct = (
2816
+ (exit_price - trade.entry_price) / trade.entry_price
2817
+ if trade.direction == TradeDirection.LONG and trade.entry_price > 0
2818
+ else (trade.entry_price - exit_price) / trade.entry_price
2819
+ if trade.entry_price > 0
2820
+ else 0.0
2821
  )
2822
  self.ranker_logger.trade_close(
2823
+ trade_id = trade_id,
2824
+ asset = trade.asset,
2825
+ pnl = trade.realized_pnl,
2826
+ return_pct = return_pct,
2827
  )
2828
 
2829
+ logger.info(
2830
+ f"πŸ”΄ [{trade.asset}] TRADE CLOSED | trade_id={trade_id} | "
2831
+ f"status={status} | profit={profit:+.4f} | "
2832
+ f"contract_id={trade.contract_id}"
2833
+ )
2834
+ return trade
2835
+
2836
+ # ── Legacy compatibility wrapper ───────────────────────────────────────────
2837
+
2838
+ def open_trade(
2839
+ self,
2840
+ trade_id: str,
2841
+ asset: str,
2842
+ direction: TradeDirection,
2843
+ entry_price: float,
2844
+ quantity: float,
2845
+ ) -> Trade:
2846
+ """
2847
+ Backward-compatibility shim β€” delegates to register_pending_buy().
2848
+ entry_price is stored as a non-authoritative hint; it will be
2849
+ overwritten by the broker's entry_tick on confirm_buy().
2850
+ """
2851
+ trade = self.register_pending_buy(trade_id, asset, direction, quantity)
2852
+ trade.entry_price = entry_price # non-authoritative hint
2853
  return trade
2854
 
2855
+ def close_trade(
2856
+ self,
2857
+ trade_id: str,
2858
+ exit_price: float,
2859
+ fees: float = 0.0,
2860
+ ) -> Optional[Trade]:
2861
+ """
2862
+ Backward-compatibility shim β€” accepts an exit_price for callers that
2863
+ previously supplied it. Delegates to close_trade_from_broker() with
2864
+ profit derived from broker data if available, otherwise uses the passed
2865
+ exit_price only for the reward calculator (log-return path).
2866
+ Does NOT simulate fills or invent authoritative P&L.
2867
+ """
2868
+ with self._lock:
2869
+ trade = self._open_trades.get(trade_id)
2870
+ if trade is None:
2871
+ logger.warning(f"⚠️ Trade {trade_id} not found")
2872
+ return None
2873
+
2874
+ # Use broker profit if already set; otherwise treat exit_price as
2875
+ # best-available approximation (e.g. during optional-stopping exit
2876
+ # before broker sends terminal event).
2877
+ profit = (
2878
+ trade.profit
2879
+ if trade.profit is not None
2880
+ else (
2881
+ (exit_price - trade.entry_price) * trade.quantity - fees
2882
+ if trade.direction == TradeDirection.LONG
2883
+ else (trade.entry_price - exit_price) * trade.quantity - fees
2884
+ )
2885
+ )
2886
+ return self.close_trade_from_broker(
2887
+ trade_id = trade_id,
2888
+ status = trade.status or "sold",
2889
+ profit = profit,
2890
+ sell_price = exit_price,
2891
+ )
2892
+
2893
+ # ── Queries ────────────────────────────────────────────────────────────────
2894
+
2895
  def get_open_trades(self) -> List[Trade]:
2896
+ """Return all trades that are PENDING, OPEN, or CLOSING (i.e. not CLOSED)."""
2897
  with self._lock:
2898
  return list(self._open_trades.values())
2899
 
2900
+ def get_confirmed_open_trades(self) -> List[Trade]:
2901
+ """Return only broker-confirmed OPEN or CLOSING trades (not PENDING)."""
2902
+ with self._lock:
2903
+ return [
2904
+ t for t in self._open_trades.values()
2905
+ if t.state in (PositionState.OPEN, PositionState.CLOSING)
2906
+ ]
2907
+
2908
  def get_open_trade_by_asset(self, asset: str) -> Optional[Trade]:
2909
  with self._lock:
2910
  for t in self._open_trades.values():
 
2912
  return t
2913
  return None
2914
 
2915
+ def get_open_trade_by_contract(self, contract_id: str) -> Optional[Trade]:
2916
+ with self._lock:
2917
+ for t in self._open_trades.values():
2918
+ if t.contract_id == str(contract_id):
2919
+ return t
2920
+ return None
2921
+
2922
  def get_unrealized_pnl(self, price_map: Dict[str, float]) -> float:
2923
+ """Non-authoritative spot estimate only."""
2924
  with self._lock:
2925
  return sum(
2926
  t.compute_unrealized_pnl(price_map.get(t.asset, t.entry_price))
 
3272
  logger.error(f"❌ Subscription error for {symbol}: {e}")
3273
  return False
3274
 
3275
+ async def subscribe_to_poc(self, contract_id: str) -> bool:
3276
+ """
3277
+ Subscribe to proposal_open_contract stream for a live contract.
3278
+ Delivers real-time status updates and terminal events (won/lost/sold/expired).
3279
+ Must be called after buy confirmation so we receive all lifecycle events.
3280
+ """
3281
+ try:
3282
+ await self.ws.send(json.dumps({
3283
+ "proposal_open_contract": 1,
3284
+ "contract_id": int(contract_id),
3285
+ "subscribe": 1,
3286
+ "req_id": self._next_msg_id(),
3287
+ }))
3288
+ logger.info(f"πŸ”” Subscribed to poc stream | contract_id={contract_id}")
3289
+ return True
3290
+ except Exception as e:
3291
+ logger.error(f"❌ poc subscription error | contract_id={contract_id}: {e}")
3292
+ return False
3293
+
3294
+ async def forget_contract(self, subscription_id: str) -> None:
3295
+ """Unsubscribe from a poc stream after contract closes to avoid leaking subscriptions."""
3296
+ try:
3297
+ await self.ws.send(json.dumps({
3298
+ "forget": subscription_id,
3299
+ "req_id": self._next_msg_id(),
3300
+ }))
3301
+ except Exception:
3302
+ pass # best-effort cleanup
3303
+
3304
  async def send_message(self, msg: dict) -> bool:
3305
  try:
3306
  msg["req_id"] = self._next_msg_id()
 
3679
  # ── Deriv message handling ─────────────────────────────────────────────────────────
3680
 
3681
  def _on_deriv_message(self, msg: dict) -> None:
3682
+ """
3683
+ Synchronous handler for Deriv WebSocket messages.
3684
+
3685
+ Handles:
3686
+ tick β€” price update
3687
+ buy β€” contract open confirmation (PENDING β†’ OPEN)
3688
+ proposal_open_contract β€” live contract update; terminal event β†’ CLOSED
3689
+ balance β€” account balance sync to portfolio risk manager
3690
+ error β€” broker rejection log
3691
+ """
3692
  try:
3693
  if "tick" in msg:
3694
  self._on_price_tick(msg["tick"])
3695
+
3696
  elif "buy" in msg:
3697
+ self._on_buy_confirmation(msg)
3698
+
3699
+ elif "proposal_open_contract" in msg:
3700
+ self._on_poc_update(msg["proposal_open_contract"], msg)
3701
+
3702
+ elif "balance" in msg:
3703
+ bal = msg["balance"]
3704
+ new_equity = float(bal.get("balance", 0.0))
3705
+ self.portfolio_risk_mgr.update_equity(new_equity)
3706
+ logger.debug(
3707
+ f"[Deriv] Balance update | equity={new_equity:.2f}"
3708
+ )
3709
+
3710
+ elif "error" in msg:
3711
+ err = msg["error"]
3712
+ code = err.get("code", "UNKNOWN")
3713
+ message = err.get("message", str(err))
3714
+ req_id = msg.get("req_id")
3715
+ # Clean up any pending trade associated with this req_id
3716
+ trade_id = self._pending_req_to_trade.pop(req_id, None)
3717
+ if trade_id:
3718
+ # Remove the pending trade stub β€” broker rejected the buy
3719
  with self.position_mgr._lock:
3720
+ rejected = self.position_mgr._open_trades.pop(trade_id, None)
3721
+ if rejected:
3722
+ logger.error(
3723
+ f"❌ [{rejected.asset}] BROKER REJECTED buy | "
3724
+ f"trade_id={trade_id} | code={code} | {message}"
3725
+ )
3726
+ # Legacy-compatible execution failure log
3727
+ logger.error(
3728
+ f"[{rejected.asset}] EXECUTION FAILED | "
3729
+ f"reason=broker_rejected | code={code} | {message}"
3730
+ )
3731
+ else:
3732
+ logger.error(
3733
+ f"❌ Deriv error (req_id={req_id}): [{code}] {message}"
3734
+ )
3735
  else:
3736
+ logger.error(
3737
+ f"❌ Deriv error (req_id={req_id}): [{code}] {message}"
 
3738
  )
 
 
3739
  if self.ranker_logger:
3740
  self.ranker_logger.connection_event("Deriv", "error", str(msg["error"]))
3741
+
3742
  except Exception as e:
3743
  logger.error(f"❌ Deriv message handler error: {e}")
3744
+ traceback.print_exc()
3745
+
3746
+ def _on_buy_confirmation(self, msg: dict) -> None:
3747
+ """
3748
+ Handle broker buy confirmation: PENDING β†’ OPEN.
3749
+ Bind contract_id, entry_tick, buy_price to the Trade object.
3750
+ Then subscribe to the proposal_open_contract stream for live updates.
3751
+ """
3752
+ buy_data = msg["buy"]
3753
+ req_id = msg.get("req_id")
3754
+ trade_id = self._pending_req_to_trade.pop(req_id, None)
3755
+ contract_id = str(buy_data.get("contract_id", ""))
3756
+
3757
+ if not contract_id:
3758
+ logger.error(
3759
+ f"❌ Buy confirmation missing contract_id | req_id={req_id}"
3760
+ )
3761
+ return
3762
+
3763
+ # Map contract_id β†’ trade_id for poc routing
3764
+ if trade_id:
3765
+ self._contract_to_trade[contract_id] = trade_id
3766
+
3767
+ buy_price = float(buy_data.get("buy_price", 0.0))
3768
+ start_tick = float(buy_data.get("start_time", time.time()))
3769
+ # Deriv may provide longcode/shortcode/transaction_id directly in buy
3770
+ shortcode = buy_data.get("shortcode")
3771
+ tx_id = str(buy_data.get("transaction_id", ""))
3772
+ # Use spot price at confirmation as entry_tick (broker-authoritative)
3773
+ entry_tick = float(
3774
+ buy_data.get("spot", 0.0)
3775
+ or buy_data.get("entry_spot", 0.0)
3776
+ or 0.0
3777
+ )
3778
+ # If Deriv didn't include spot in buy, use current streamer price
3779
+ if entry_tick == 0.0 and trade_id:
3780
+ pending = self.position_mgr._open_trades.get(trade_id)
3781
+ if pending:
3782
+ s = self.price_streamers.get(pending.asset)
3783
+ entry_tick = s.latest_mid if s else 0.0
3784
+ broker_symbol = buy_data.get("symbol", "")
3785
+
3786
+ if trade_id:
3787
+ trade = self.position_mgr.confirm_buy(
3788
+ trade_id = trade_id,
3789
+ contract_id = contract_id,
3790
+ buy_price = buy_price,
3791
+ entry_tick = entry_tick,
3792
+ transaction_id = tx_id,
3793
+ shortcode = shortcode,
3794
+ broker_symbol = broker_symbol,
3795
+ )
3796
+ if trade:
3797
+ # Legacy-compatible log: "TRADE OPENED"
3798
+ logger.info(
3799
+ f"[{trade.asset}] TRADE OPENED | trade_id={trade_id} | "
3800
+ f"contract_id={contract_id} | buy_price={buy_price:.4f}"
3801
+ )
3802
+ # Subscribe to poc stream for live status + terminal event
3803
+ if self.ws_client and self.ws_client.connected:
3804
+ asyncio.get_event_loop().create_task(
3805
+ self.ws_client.subscribe_to_poc(contract_id)
3806
+ )
3807
+ else:
3808
+ logger.warning(
3809
+ f"[Deriv] Buy confirmation β€” no trade_id for req_id={req_id} | "
3810
+ f"contract_id={contract_id} (late or orphaned confirmation)"
3811
+ )
3812
+
3813
+ def _on_poc_update(self, poc: dict, raw_msg: dict) -> None:
3814
+ """
3815
+ Handle a proposal_open_contract update.
3816
+
3817
+ Live tick: update current_spot on the Trade object.
3818
+ Terminal: close the trade with broker-authoritative profit data.
3819
+ """
3820
+ contract_id = str(poc.get("contract_id", ""))
3821
+ trade_id = self._contract_to_trade.get(contract_id)
3822
+
3823
+ if not trade_id:
3824
+ # Not a trade we're tracking β€” ignore
3825
+ return
3826
+
3827
+ with self.position_mgr._lock:
3828
+ trade = self.position_mgr._open_trades.get(trade_id)
3829
+
3830
+ if trade is None:
3831
+ # Already closed (race condition or duplicate terminal) β€” ignore
3832
+ return
3833
+
3834
+ # ── Update current_spot on live ticks ────────────────────────────────
3835
+ current_spot = poc.get("current_spot")
3836
+ if current_spot:
3837
+ trade.current_spot = float(current_spot)
3838
+ # Update streamer-level price context for feature engine
3839
+ streamer = self.price_streamers.get(trade.asset)
3840
+ if streamer:
3841
+ trade.unrealized_pnl = trade.compute_unrealized_pnl(float(current_spot))
3842
+
3843
+ # ── Terminal state check ──────────────────────────────────────────────
3844
+ is_terminal = (
3845
+ poc.get("is_expired", False)
3846
+ or poc.get("is_sold", False)
3847
+ or poc.get("status") in ("won", "lost", "sold", "expired")
3848
+ )
3849
+
3850
+ if not is_terminal:
3851
+ return # live update only β€” nothing to close yet
3852
+
3853
+ # ── Extract broker-authoritative close data ───────────────────────────
3854
+ status = poc.get("status", "expired")
3855
+ raw_profit = poc.get("profit")
3856
+ if raw_profit is None:
3857
+ raw_profit = poc.get("bid_price", 0.0)
3858
+ profit = float(raw_profit) if raw_profit is not None else 0.0
3859
+ sell_price = float(poc.get("sell_price") or poc.get("bid_price") or 0.0)
3860
+ exit_tick = float(poc.get("exit_tick") or poc.get("current_spot") or 0.0)
3861
+
3862
+ logger.info(
3863
+ f"[{trade.asset}] CONTRACT TERMINAL | contract_id={contract_id} | "
3864
+ f"status={status} | profit={profit:+.4f} | sell_price={sell_price:.4f}"
3865
+ )
3866
+
3867
+ closed_trade = self.position_mgr.close_trade_from_broker(
3868
+ trade_id = trade_id,
3869
+ status = status,
3870
+ profit = profit,
3871
+ sell_price = sell_price if sell_price > 0 else None,
3872
+ exit_tick = exit_tick if exit_tick > 0 else None,
3873
+ )
3874
+
3875
+ if closed_trade is None:
3876
+ return
3877
+
3878
+ # ── Replay, bandit, and portfolio accounting ──────────────────────────
3879
+ fees = 0.0 # already reflected in broker profit
3880
+ reward = self._reward_from_broker(closed_trade)
3881
+
3882
+ self.portfolio_risk_mgr.register_close(trade_id, closed_trade.realized_pnl)
3883
+ self._close_pending_episode(trade_id, reward)
3884
+ self._trade_tick_counts.pop(trade_id, None)
3885
+
3886
+ self.stats["trades_closed"] += 1
3887
+ self.stats["total_pnl"] += closed_trade.realized_pnl
3888
+
3889
+ # Legacy-compatible log: "TRADE CLOSED"
3890
+ logger.info(
3891
+ f"πŸ’° [{closed_trade.asset}] TRADE CLOSED | "
3892
+ f"reward={reward:+.6f} | profit={profit:+.4f} | "
3893
+ f"portfolio_dd={self.portfolio_risk_mgr._current_drawdown():.2%}"
3894
+ )
3895
+
3896
+ # Clean up contract mapping
3897
+ self._contract_to_trade.pop(contract_id, None)
3898
+
3899
+ # Subscription cleanup (best-effort)
3900
+ sub_id = poc.get("id") or poc.get("subscription", {}).get("id")
3901
+ if sub_id and self.ws_client and self.ws_client.connected:
3902
+ asyncio.get_event_loop().create_task(
3903
+ self.ws_client.forget_contract(sub_id)
3904
+ )
3905
+
3906
+ # REFILL TRIGGER β€” if open count drops below floor after terminal event
3907
+ open_count = len(self.position_mgr.get_open_trades())
3908
+ if open_count < 2 and self.running:
3909
+ logger.warning(
3910
+ f"[poc_terminal] ⚠️ REFILL TRIGGER β€” "
3911
+ f"open_count={open_count} < 2 after contract terminal. "
3912
+ f"Scheduling immediate rank_and_gate() to restore minimum."
3913
+ )
3914
+ asyncio.get_event_loop().create_task(self._safe_rank_and_gate())
3915
+
3916
+ async def _safe_rank_and_gate(self) -> None:
3917
+ """Wrapper that silently catches errors from refill-triggered rank_and_gate()."""
3918
+ try:
3919
+ await self.rank_and_gate()
3920
+ except Exception as e:
3921
+ logger.error(f"[refill] rank_and_gate error: {e}")
3922
+
3923
+ def _reward_from_broker(self, trade: "Trade") -> float:
3924
+ """
3925
+ Compute replay reward from broker-authoritative profit.
3926
+ Uses log-return form [S4] if entry_tick and exit data are available;
3927
+ falls back to raw profit / stake ratio otherwise.
3928
+ """
3929
+ stake = self.trade_config.amount
3930
+ entry = trade.entry_tick if trade.entry_tick and trade.entry_tick > 0 else trade.entry_price
3931
+ exit_ = trade.sell_price or trade.exit_price or 0.0
3932
+ fees = self.trade_config.commission_rate
3933
+ slip = self.trade_config.slippage_bps / 10_000.0
3934
+
3935
+ if entry > 0 and exit_ > 0:
3936
+ log_ret = math.log(exit_ / entry)
3937
+ if trade.direction == TradeDirection.SHORT:
3938
+ log_ret = -log_ret
3939
+ return float(log_ret - fees - slip)
3940
+
3941
+ # Fallback: normalise broker profit by stake
3942
+ return float(trade.profit / stake) if stake > 0 and trade.profit is not None else 0.0
3943
 
3944
  def _on_price_tick(self, tick_data: dict) -> None:
3945
  try: