File size: 6,992 Bytes
fb9437e
40b816f
8f27110
73faa44
 
fb9437e
 
40ec9eb
 
 
 
40b816f
2270a6d
 
40b816f
40ec9eb
40b816f
40ec9eb
40b816f
 
40ec9eb
 
 
 
 
 
 
2270a6d
 
40ec9eb
 
 
 
 
2270a6d
 
 
40ec9eb
73faa44
40ec9eb
 
fb9437e
73faa44
40ec9eb
fb9437e
 
40ec9eb
 
 
 
fb9437e
 
 
40ec9eb
 
 
 
 
 
 
 
 
 
 
 
 
 
73faa44
 
 
 
 
8f27110
 
 
2270a6d
 
fb9437e
2270a6d
 
 
 
 
8f27110
73faa44
2270a6d
 
73faa44
40ec9eb
73faa44
40ec9eb
 
2270a6d
 
 
 
 
 
8f27110
73faa44
 
 
 
40ec9eb
 
 
 
 
 
 
 
 
 
8f27110
 
73faa44
 
40ec9eb
 
 
fb9437e
40ec9eb
8f27110
fb9437e
 
 
73faa44
fb9437e
40ec9eb
73faa44
40ec9eb
fb9437e
 
 
 
40ec9eb
 
 
 
fb9437e
8f27110
 
 
73faa44
 
 
fb9437e
8f27110
fb9437e
40ec9eb
8f27110
40ec9eb
8f27110
40ec9eb
8f27110
 
 
fb9437e
73faa44
fb9437e
73faa44
fb9437e
73faa44
fb9437e
73faa44
fb9437e
73faa44
 
8f27110
40ec9eb
2270a6d
fb9437e
8f27110
73faa44
fb9437e
8f27110
 
 
 
fb9437e
2270a6d
 
8f27110
40b816f
40ec9eb
 
 
2270a6d
40ec9eb
fb9437e
8f27110
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# simulation_engine/virtual_exchange.py
# V2.0 — Single-position engine with cooldown and soft/hard time-stops (≈1h / 2h)

from datetime import datetime
import uuid

class VirtualExchange:
    def __init__(
        self,
        initial_balance=10.0,
        fee_rate=0.001,
        tp_pct=1.6,
        sl_pct=0.8,
        bar_ms=300_000,            # 5m
        cooldown_bars=1,
        min_trade_usd=0.10,
        position_fraction=0.45,
        max_concurrent=1,
        soft_time_stop_bars=12,    # ~1h at 5m
        hard_time_stop_bars=24,    # ~2h at 5m
    ):
        self.initial_balance = float(initial_balance)
        self.balance = float(initial_balance)
        self.fee_rate = float(fee_rate)

        self.tp_pct = float(tp_pct) / 100.0
        self.sl_pct = float(sl_pct) / 100.0

        self.bar_ms = int(bar_ms) if bar_ms else None
        self.cooldown_bars = int(cooldown_bars) if cooldown_bars is not None else 0
        self.min_trade_usd = float(min_trade_usd)
        self.position_fraction = float(position_fraction)
        self.max_concurrent = int(max_concurrent)

        self.soft_time_stop_bars = int(soft_time_stop_bars) if soft_time_stop_bars else None
        self.hard_time_stop_bars = int(hard_time_stop_bars) if hard_time_stop_bars else None

        self.positions = {}          # symbol -> position dict
        self.trade_history = []
        self.cooldowns = {}          # symbol -> cooldown_until_ts

        self.metrics = {
            "wins": 0, "losses": 0, "total_pnl_usd": 0.0,
            "max_drawdown": 0.0, "peak_balance": self.balance
        }

    # ------- Helpers -------
    def open_positions_count(self):
        return len(self.positions)

    def get_balance(self):
        return self.balance

    def can_enter(self, symbol, current_ts):
        cd_until = self.cooldowns.get(symbol)
        if cd_until is not None and current_ts < cd_until:
            return False
        if self.open_positions_count() >= self.max_concurrent:
            return False
        if symbol in self.positions:
            return False
        planned = self.balance * self.position_fraction
        if planned < self.min_trade_usd:
            return False
        return True

    # ------- Lifecycle -------
    def update_positions(self, current_prices, timestamp):
        closed_trades = []
        for symbol in list(self.positions.keys()):
            pos = self.positions[symbol]
            current_price = current_prices.get(symbol)
            if not current_price:
                continue

            entry = pos["entry_price"]
            pos["highest_price"] = max(pos.get("highest_price", entry), current_price)

            tp_price = entry * (1.0 + self.tp_pct)
            sl_price = entry * (1.0 - self.sl_pct)

            age_ms = timestamp - pos["entry_time"]
            age_bars = int(age_ms // self.bar_ms) if self.bar_ms else 0

            closed_trade = None

            # 1) Price-based exits
            if current_price >= tp_price:
                closed_trade = self._close(symbol, tp_price, timestamp, "TAKE_PROFIT")
            elif current_price <= sl_price:
                closed_trade = self._close(symbol, sl_price, timestamp, "STOP_LOSS")
            else:
                # 2) Time-based exits: soft then hard
                if self.soft_time_stop_bars and age_bars >= self.soft_time_stop_bars:
                    if current_price >= entry:
                        closed_trade = self._close(symbol, current_price, timestamp, "SOFT_TIME_STOP")
                if not closed_trade and self.hard_time_stop_bars and age_bars >= self.hard_time_stop_bars:
                    closed_trade = self._close(symbol, current_price, timestamp, "HARD_TIME_STOP")

            if closed_trade:
                closed_trades.append(closed_trade)
        return closed_trades

    def execute_buy(self, symbol, price, timestamp, score_data=None):
        if price <= 0:
            return False
        if self.open_positions_count() >= self.max_concurrent:
            return False
        if symbol in self.positions:
            return False

        amount_usd = self.balance * self.position_fraction
        if amount_usd < self.min_trade_usd or amount_usd > self.balance:
            return False

        fee = amount_usd * self.fee_rate
        net_invested = amount_usd - fee
        if net_invested <= 0:
            return False

        quantity = net_invested / price
        self.balance -= amount_usd

        self.positions[symbol] = {
            "entry_price": price,
            "quantity": quantity,
            "invested_usd": amount_usd,
            "entry_time": timestamp,
            "scores": score_data or {},
            "highest_price": price,
            "trade_id": f"sim_{uuid.uuid4().hex[:8]}",
        }
        return True

    def execute_sell(self, symbol, price, timestamp, reason):
        return self._close(symbol, price, timestamp, reason)

    # ------- Internal close -------
    def _close(self, symbol, price, timestamp, reason):
        pos = self.positions.get(symbol)
        if not pos:
            return None

        revenue = pos["quantity"] * price
        fee = revenue * self.fee_rate
        net_revenue = revenue - fee
        self.balance += net_revenue

        pnl_usd = net_revenue - pos["invested_usd"]
        pnl_pct = (pnl_usd / pos["invested_usd"]) * 100.0

        entry_score = None
        try:
            entry_score = float((pos.get("scores") or {}).get("final_score"))
        except Exception:
            entry_score = None

        trade_record = {
            "id": pos["trade_id"],
            "symbol": symbol,
            "status": "CLOSED",
            "entry_time": datetime.fromtimestamp(pos["entry_time"] / 1000).isoformat(),
            "close_time": datetime.fromtimestamp(timestamp / 1000).isoformat(),
            "entry_price": pos["entry_price"],
            "close_price": price,
            "pnl_usd": pnl_usd,
            "pnl_percent": pnl_pct,
            "close_reason": reason,
            "strategy": "HYBRID_TITAN",
            "score": entry_score,
            "decision_data": {"components": pos.get("scores") or {}}
        }

        self.trade_history.append(trade_record)
        self.metrics["total_pnl_usd"] += pnl_usd
        if pnl_usd > 0:
            self.metrics["wins"] += 1
        else:
            self.metrics["losses"] += 1
        self.metrics["peak_balance"] = max(self.metrics["peak_balance"], self.balance)
        current_dd = (self.metrics["peak_balance"] - self.balance) / max(self.metrics["peak_balance"], 1e-9)
        self.metrics["max_drawdown"] = max(self.metrics["max_drawdown"], current_dd)

        # تبريد لكل رمز
        if self.cooldown_bars and self.bar_ms:
            self.cooldowns[symbol] = timestamp + self.cooldown_bars * self.bar_ms
        else:
            self.cooldowns[symbol] = None

        del self.positions[symbol]
        return trade_record