Spaces:
Running
Running
Upload Quasar_axrvi_ranker.py
Browse files- 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", "
|
| 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 |
-
|
| 389 |
-
|
| 390 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
exit_price: Optional[float] = None
|
| 414 |
exit_time: Optional[float] = None
|
| 415 |
-
|
| 416 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
fees: float = 0.0
|
| 418 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
|
| 420 |
def compute_unrealized_pnl(self, current_price: float) -> float:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
if self.direction == TradeDirection.LONG:
|
| 422 |
-
return (current_price -
|
| 423 |
-
return (
|
| 424 |
|
| 425 |
def compute_unrealized_return(self, current_price: float) -> float:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
if self.direction == TradeDirection.LONG:
|
| 427 |
-
return (current_price -
|
| 428 |
-
return (
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2552 |
|
| 2553 |
def __init__(self, ranker_logger: Optional[object] = None):
|
| 2554 |
-
self._open_trades:
|
| 2555 |
-
self._closed_trades:
|
| 2556 |
-
self._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 |
-
|
|
|
|
|
|
|
| 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,
|
| 2587 |
-
|
|
|
|
|
|
|
|
|
|
| 2588 |
)
|
| 2589 |
|
| 2590 |
logger.info(
|
| 2591 |
-
f"β
[{asset}] TRADE OPENED |
|
| 2592 |
-
f"
|
|
|
|
| 2593 |
)
|
| 2594 |
return trade
|
| 2595 |
|
| 2596 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2597 |
self,
|
| 2598 |
trade_id: str,
|
| 2599 |
-
|
| 2600 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
(
|
| 2616 |
-
if trade.direction == TradeDirection.LONG
|
| 2617 |
-
else (trade.entry_price -
|
|
|
|
|
|
|
| 2618 |
)
|
| 2619 |
self.ranker_logger.trade_close(
|
| 2620 |
-
trade_id=trade_id,
|
| 2621 |
-
|
|
|
|
|
|
|
| 2622 |
)
|
| 2623 |
|
| 2624 |
-
logger.info(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3369 |
try:
|
| 3370 |
if "tick" in msg:
|
| 3371 |
self._on_price_tick(msg["tick"])
|
|
|
|
| 3372 |
elif "buy" in msg:
|
| 3373 |
-
|
| 3374 |
-
|
| 3375 |
-
|
| 3376 |
-
|
| 3377 |
-
|
| 3378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3379 |
with self.position_mgr._lock:
|
| 3380 |
-
|
| 3381 |
-
|
| 3382 |
-
|
| 3383 |
-
|
| 3384 |
-
|
| 3385 |
-
|
| 3386 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3387 |
else:
|
| 3388 |
-
logger.
|
| 3389 |
-
f"
|
| 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:
|