Files
polymarket-5min-15min-1hour…/up-down-spread-bot/src/trader.py
T

1346 lines
60 KiB
Python
Raw Normal View History

2026-07-26 22:56:35 +08:00
"""
Position management with support for multiple entries per market
"""
import time
import json
import threading
from typing import Dict, List, Optional
from pathlib import Path
# Global dependencies (injected externally)
_order_executor = None
_data_feed = None # ✅ For access to position_tracker (REAL data!)
_token_ids_cache = {} # {market_slug: {'UP': token_id, 'DOWN': token_id}}
_market_metadata_cache = {} # {market_slug: {'condition_id': str, 'neg_risk': bool}}
# Persistent storage for metadata (critical for redeem after restart!)
_METADATA_FILE = Path("logs/market_metadata.json")
def set_order_executor(executor):
"""Inject OrderExecutor for real trading"""
global _order_executor
_order_executor = executor
print("[TRADER] ✓ OrderExecutor injected")
def set_data_feed(data_feed):
"""Inject DataFeed for access to REAL positions"""
global _data_feed
_data_feed = data_feed
print("[TRADER] ✅ DataFeed injected (REAL position tracking)")
def save_market_metadata_to_disk():
"""
💾 Save metadata to disk (CRITICAL for redeem after restart!)
Metadata includes:
- token_ids (UP, DOWN)
- condition_id (for redeem)
- neg_risk flag
WITHOUT this redeem after restart is IMPOSSIBLE!
"""
try:
_METADATA_FILE.parent.mkdir(exist_ok=True)
# Merge token_ids and metadata into one dict
combined = {}
for market_slug in _token_ids_cache:
combined[market_slug] = {
'token_ids': _token_ids_cache[market_slug],
'metadata': _market_metadata_cache.get(market_slug, {})
}
with open(_METADATA_FILE, 'w') as f:
json.dump(combined, f, indent=2)
# print(f"[TRADER] 💾 Saved metadata for {len(combined)} markets to disk")
except Exception as e:
print(f"[TRADER] ⚠️ Failed to save metadata: {e}")
def load_market_metadata_from_disk():
"""
📂 Load metadata from disk at startup
This is critical for:
- Redeeming positions after restart
- EMERGENCY_SAVE positions (loaded from trades.jsonl)
"""
global _token_ids_cache, _market_metadata_cache
if not _METADATA_FILE.exists():
print("[TRADER] ️ No metadata file found (first run or clean start)")
return
try:
with open(_METADATA_FILE, 'r') as f:
combined = json.load(f)
# Restore caches
for market_slug, data in combined.items():
if 'token_ids' in data:
_token_ids_cache[market_slug] = data['token_ids']
if 'metadata' in data:
_market_metadata_cache[market_slug] = data['metadata']
print(f"[TRADER] ✅ Loaded metadata for {len(combined)} markets from disk")
except Exception as e:
print(f"[TRADER] ⚠️ Failed to load metadata: {e}")
def set_token_ids(market_slug: str, up_token_id: str, down_token_id: str,
condition_id: str = "", neg_risk: bool = True):
"""Cache token IDs and metadata for market + save to disk!"""
global _token_ids_cache, _market_metadata_cache
_token_ids_cache[market_slug] = {
'UP': up_token_id,
'DOWN': down_token_id
}
_market_metadata_cache[market_slug] = {
'condition_id': condition_id,
'neg_risk': neg_risk
}
# 💾 CRITICAL: Save to disk for redeem after restart!
save_market_metadata_to_disk()
def get_token_ids(market_slug: str) -> dict:
"""Get token IDs for market"""
return _token_ids_cache.get(market_slug, {})
def get_market_metadata(market_slug: str) -> dict:
"""Get metadata (condition_id, neg_risk) for market"""
return _market_metadata_cache.get(market_slug, {})
class Trader:
"""Manage trading positions with detailed entry tracking"""
def __init__(self, capital: float, log_dir: str = "logs", config: dict = None):
self.starting_capital = capital
self.current_capital = capital
# Config for stop-loss checks
self.config = config
# Positions: {market_slug: {'UP': {...}, 'DOWN': {...}, 'entries': [...], ...}}
self.positions = {}
# Closed trades history
self.closed_trades = []
# Track closed markets to prevent re-entry after early exit
self.closed_markets = set() # Markets that were closed (early exit or normal)
# 🛡️ THREAD SAFETY: Lock for async operations
self.lock = threading.RLock() # Reentrant lock (avoids deadlock)
# Market statistics tracking
self.market_max_drawdown = {} # {market_slug: max_dd_value}
self.market_entries_count = {} # {market_slug: count}
# Logging
self.log_dir = Path(log_dir)
self.trades_file = self.log_dir / "trades.jsonl"
self.session_file = self.log_dir / "session.json"
print(f"[TRADER] Initialized with ${capital:,.2f} capital")
# Load previous trades to restore statistics
self.load_previous_trades()
def load_previous_trades(self):
"""
Load previous trades from trades.jsonl to restore statistics
This allows bot to continue from where it left off after restart
"""
if not self.trades_file.exists():
print(f"[TRADER] No previous trades file found (this is OK for first run)")
return
try:
loaded_count = 0
corrupted_lines = 0
with open(self.trades_file, 'r') as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line:
continue # Skip empty lines
try:
trade = json.loads(line)
# Validate trade has required fields
if 'pnl' not in trade or 'market_slug' not in trade:
print(f"[WARNING] Trade on line {line_num} missing required fields, skipping")
corrupted_lines += 1
continue
self.closed_trades.append(trade)
loaded_count += 1
except json.JSONDecodeError as e:
print(f"[WARNING] Corrupted JSON on line {line_num}: {e}")
corrupted_lines += 1
continue
if loaded_count > 0:
# Recalculate current capital from loaded trades
total_pnl = sum(t['pnl'] for t in self.closed_trades)
self.current_capital = self.starting_capital + total_pnl
# Get stats
wins = sum(1 for t in self.closed_trades if t['pnl'] > 0)
win_rate = (wins / loaded_count * 100) if loaded_count > 0 else 0
print(f"[TRADER] ✓ Loaded {loaded_count} previous trade(s)")
print(f"[TRADER] Cumulative PnL: ${total_pnl:+,.2f}")
print(f"[TRADER] Win Rate: {win_rate:.1f}% ({wins}/{loaded_count})")
print(f"[TRADER] Current Capital: ${self.current_capital:,.2f}")
if corrupted_lines > 0:
print(f"[TRADER] ⚠ Skipped {corrupted_lines} corrupted line(s)")
else:
print(f"[TRADER] No valid trades found in file")
except Exception as e:
print(f"[TRADER] ⚠ Error loading previous trades: {e}")
print(f"[TRADER] Starting fresh with capital ${self.starting_capital:,.2f}")
# Reset to fresh state on error
self.closed_trades = []
self.current_capital = self.starting_capital
def enter_position_contracts(self, market_slug: str, side: str, price: float, contracts: int,
up_ask: float = None, down_ask: float = None,
winner_ratio: float = 0.0, is_recovery: bool = False,
entry_reason: str = 'normal',
seconds_till_end: int = 0, time_from_start: int = 0) -> bool:
"""
Enter a position by specifying number of contracts/shares
🛡️ THREAD-SAFE: can be called from different threads
Args:
market_slug: Market identifier
side: 'UP' or 'DOWN'
price: Entry price
contracts: Number of contracts/shares to buy
up_ask: Current UP ask price (for detailed logging)
down_ask: Current DOWN ask price (for detailed logging)
winner_ratio: Current winner ratio (for detailed logging)
is_recovery: Is this a recovery entry? (for detailed logging)
entry_reason: Reason for entry (for detailed logging)
seconds_till_end: Seconds until market end (for detailed logging)
time_from_start: Seconds from market start (for detailed logging)
Returns:
True if entered successfully
"""
# Skip if contracts is 0 (hedge with no position)
if contracts == 0:
return True # Success, just didn't enter anything
# Note: Market closure check now handled in main.py (market_start_prices)
# This provides single source of truth and auto-cleanup on market switch
# Calculate position size in USD
size_usd = contracts * price
shares = float(contracts)
# Track entry count for ratio calculation
if not hasattr(self, '_entry_count'):
self._entry_count = 0
self._entry_count += 1
# 🔥 FIRST TRY TO BUY (if live mode)
actual_contracts = shares
actual_cost = size_usd
if _order_executor and market_slug in _token_ids_cache:
token_id = _token_ids_cache[market_slug][side]
ask_price = up_ask if side == 'UP' else down_ask
if token_id and ask_price:
print(f"[TRADER] ▶ {side:4s} @ ${price:.3f} {shares:6.1f} contracts = ${size_usd:6.2f} ({market_slug})")
result = _order_executor.place_buy_order(
market_slug=market_slug,
token_id=token_id,
side=side,
contracts=contracts,
ask_price=ask_price
)
if result.success:
# ✅ SUCCESS! Using ACTUAL filled amounts
actual_contracts = result.filled_size
actual_cost = result.total_spent_usd
if actual_contracts != contracts:
print(f"[TRADER] ⚠ FAK partial fill: {actual_contracts:.2f}/{contracts} contracts")
print(f"[TRADER] ✓ Order filled: {actual_contracts:.2f} contracts for ${actual_cost:.2f}")
elif not result.dry_run:
# ❌ FAILED! Don't create position at all!
print(f"[TRADER] ❌ Order FAILED for {side}: {result.error} - position NOT created")
return False
else:
# DRY_RUN or no executor - just print
print(f"[TRADER] ▶ {side:4s} @ ${price:.3f} {shares:6.1f} shares = ${size_usd:6.2f} ({market_slug})")
# NOW create position with ACTUAL values (or paper values if DRY_RUN)
if market_slug not in self.positions:
self.positions[market_slug] = {
'UP': {
'entries': [],
'total_invested': 0.0,
'total_shares': 0.0
},
'DOWN': {
'entries': [],
'total_invested': 0.0,
'total_shares': 0.0
},
'all_entries': [],
'start_time': time.time(),
'status': 'OPEN'
}
# Create entry with ACTUAL values
entry = {
'side': side,
'price': price,
'size_usd': actual_cost,
'shares': actual_contracts,
'time': time.time(),
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
'actual_fill': (_order_executor is not None) # Mark if real order
}
# Add to position
pos = self.positions[market_slug]
pos['all_entries'].append(entry)
pos[side]['entries'].append(entry)
pos[side]['total_invested'] += actual_cost
pos[side]['total_shares'] += actual_contracts
# Update market statistics
self._update_market_stats(market_slug)
# Detailed logging for backtesting
if up_ask is not None and down_ask is not None and market_slug in self.positions:
try:
self.log_entry_detailed(
market_slug=market_slug,
side=side,
contracts=actual_contracts, # Log actual
price=price,
up_ask=up_ask,
down_ask=down_ask,
winner_ratio=winner_ratio,
is_recovery=is_recovery,
entry_reason=entry_reason,
seconds_till_end=seconds_till_end,
time_from_start=time_from_start
)
except Exception as e:
# Don't fail the trade if logging fails
print(f"[WARNING] Detailed logging failed: {e}")
return True
def enter_position(self, market_slug: str, side: str, price: float, size_pct: float) -> bool:
"""
Enter a position
Args:
market_slug: Market identifier
side: 'UP' or 'DOWN'
price: Entry price
size_pct: Position size as % of capital
Returns:
True if entered successfully
"""
# Calculate position size
size_usd = self.current_capital * (size_pct / 100.0)
shares = size_usd / price if price > 0 else 0
# Create market if doesn't exist
if market_slug not in self.positions:
self.positions[market_slug] = {
'UP': {
'entries': [],
'total_invested': 0.0,
'total_shares': 0.0
},
'DOWN': {
'entries': [],
'total_invested': 0.0,
'total_shares': 0.0
},
'all_entries': [],
'start_time': time.time(),
'status': 'OPEN'
}
# Create entry
entry = {
'side': side,
'price': price,
'size_usd': size_usd,
'shares': shares,
'time': time.time(),
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S')
}
# Add to position
pos = self.positions[market_slug]
pos['all_entries'].append(entry)
pos[side]['entries'].append(entry)
pos[side]['total_invested'] += size_usd
pos[side]['total_shares'] += shares
# Update market statistics
self._update_market_stats(market_slug)
# Calculate current ratio after this entry
up_shares = pos['UP']['total_shares']
down_shares = pos['DOWN']['total_shares']
total_shares = up_shares + down_shares
if total_shares > 0 and self._entry_count % 5 == 1:
up_ratio = (up_shares / total_shares) * 100
down_ratio = (down_shares / total_shares) * 100
print(f"[TRADER] After entry: UP {up_shares:.1f} ({up_ratio:.1f}%) | DOWN {down_shares:.1f} ({down_ratio:.1f}%)")
print(f"[TRADER] ▶ {side:4s} @ ${price:.3f} {shares:6.1f} shares = ${size_usd:6.2f} ({market_slug})")
return True
def close_market(self, market_slug: str, winner: str, btc_start: float, btc_final: float) -> Optional[Dict]:
"""
Close all positions for a market
Args:
market_slug: Market identifier
winner: 'UP' or 'DOWN'
btc_start: Starting BTC price
btc_final: Final BTC price
Returns:
Trade result dict
"""
if market_slug not in self.positions:
return None
pos = self.positions[market_slug]
# Calculate PnL
winner_side = pos[winner]
loser_side = pos['UP' if winner == 'DOWN' else 'DOWN']
# Winner pays $1 per share
payout = winner_side['total_shares'] * 1.0
# Total cost
total_cost = pos['UP']['total_invested'] + pos['DOWN']['total_invested']
# PnL
pnl = payout - total_cost
roi_pct = (pnl / total_cost * 100) if total_cost > 0 else 0
# Winner ratio
total_shares = pos['UP']['total_shares'] + pos['DOWN']['total_shares']
winner_ratio = (winner_side['total_shares'] / total_shares * 100) if total_shares > 0 else 50
# Update capital
self.current_capital += pnl
# Create trade record
trade = {
'market_slug': market_slug,
'winner': winner,
'btc_start': btc_start,
'btc_final': btc_final,
'pnl': pnl,
'roi_pct': roi_pct,
'total_cost': total_cost,
'payout': payout,
'winner_ratio': winner_ratio,
'total_entries': len(pos['all_entries']),
'up_entries': len(pos['UP']['entries']),
'down_entries': len(pos['DOWN']['entries']),
'up_invested': pos['UP']['total_invested'],
'down_invested': pos['DOWN']['total_invested'],
'up_shares': pos['UP']['total_shares'],
'down_shares': pos['DOWN']['total_shares'],
'duration': time.time() - pos['start_time'],
'close_time': time.time(),
'close_timestamp': time.strftime('%Y-%m-%d %H:%M:%S')
}
# ═══════════════════════════════════════════════════════════
# CRITICAL FIX: Log trade FIRST, then delete position!
# This prevents data loss if _log_trade() fails
# ═══════════════════════════════════════════════════════════
try:
# 1. Log trade to disk FIRST (most important!)
self._log_trade(trade)
# 2. Add to memory (safe even if disk write failed)
self.closed_trades.append(trade)
# 3. Mark market as closed to prevent re-entry
self.closed_markets.add(market_slug)
# 4. NOW we can safely delete the position
del self.positions[market_slug]
# 5. Clean up market stats
if market_slug in self.market_max_drawdown:
del self.market_max_drawdown[market_slug]
if market_slug in self.market_entries_count:
del self.market_entries_count[market_slug]
except Exception as e:
# CRITICAL: If logging failed, DO NOT delete position!
# Position will remain open and can be closed again
print(f"[TRADER] ⚠️ FAILED TO CLOSE MARKET {market_slug}: {e}")
print(f"[TRADER] ⚠️ Position kept open for retry!")
return None
# Print result
status = "✓" if pnl > 0 else "✗"
print(f"[TRADER] {status} CLOSED {market_slug}: {pnl:+.2f} ({roi_pct:+.1f}%) | "
f"{trade['total_entries']} entries, ${total_cost:.0f} invested, {winner_ratio:.1f}% {winner}")
# ═══════════════════════════════════════════════════════════
# 🔥 CRITICAL: Reset investment tracking for this market!
# Now we can trade new market without limits!
# ═══════════════════════════════════════════════════════════
try:
2026-07-27 19:22:46 +08:00
if _order_executor and hasattr(_order_executor, 'safety'):
_order_executor.safety.reset_market(market_slug)
2026-07-26 22:56:35 +08:00
except Exception as reset_err:
print(f"[TRADER] ⚠ Failed to reset market tracking: {reset_err}")
return trade
def close_market_early_exit(self, market_slug: str, exit_price: float, exit_reason: str = 'early_exit',
up_bid: float = None, down_bid: float = None) -> Optional[Dict]:
"""
Early exit: close position at current favorite price
🛡️ THREAD-SAFE: can be called from different threads
Args:
market_slug: Market identifier
exit_price: Current favorite price (e.g. 0.52)
exit_reason: Reason for exit ('stop_loss', 'flip_stop', 'early_exit')
up_bid: Current UP bid price (for selling UP tokens)
down_bid: Current DOWN bid price (for selling DOWN tokens)
Returns:
Trade result dict
"""
with self.lock:
# ✅ PROTECTION #1: Check that position exists
if market_slug not in self.positions:
return None
# ✅ PROTECTION #2: Check market not closed (another thread could have closed)
if market_slug in self.closed_markets:
return None # Already closed, skip silently
pos = self.positions[market_slug]
# Get contracts
up_contracts = pos['UP']['total_shares']
down_contracts = pos['DOWN']['total_shares']
# Determine favorite (who has more contracts)
if up_contracts > down_contracts:
# UP is favorite - sell UP at exit_price, DOWN at (1 - exit_price)
payout = up_contracts * exit_price + down_contracts * (1 - exit_price)
winner = 'UP'
else:
# DOWN is favorite - sell DOWN at exit_price, UP at (1 - exit_price)
payout = down_contracts * exit_price + up_contracts * (1 - exit_price)
winner = 'DOWN'
# Total cost
total_cost = pos['UP']['total_invested'] + pos['DOWN']['total_invested']
# PnL = payout - cost
pnl = payout - total_cost
roi_pct = (pnl / total_cost * 100) if total_cost > 0 else 0
# Winner ratio
total_shares = up_contracts + down_contracts
winner_ratio = (up_contracts / total_shares * 100) if winner == 'UP' else (down_contracts / total_shares * 100)
# Update capital
self.current_capital += pnl
# ═══════════════════════════════════════════════════════════
# 📊 LOG FULL ORDERBOOK before selling (for analysis)
# ═══════════════════════════════════════════════════════════
if exit_reason in ['stop_loss', 'flip_stop']:
try:
# Get current ask prices from data_feed
up_ask = 0.5
down_ask = 0.5
if _data_feed:
market_state = _data_feed.get_state(self.coin)
up_ask = market_state.get('up_ask', 0.5)
down_ask = market_state.get('down_ask', 0.5)
self._last_orderbook_snapshot = self._capture_orderbook_snapshot(
market_slug, exit_reason,
up_bid if up_bid else (1 - exit_price),
down_bid if down_bid else exit_price,
up_ask, down_ask
)
self._log_exit_orderbook(self._last_orderbook_snapshot)
except Exception as e:
print(f"[TRADER] ⚠ Failed to log orderbook: {e}")
self._last_orderbook_snapshot = None
# Create trade record
trade = {
'market_slug': market_slug,
'winner': winner,
'exit_type': 'early_exit',
'exit_reason': exit_reason,
'exit_price': exit_price,
'pnl': pnl,
'roi_pct': roi_pct,
'total_cost': total_cost,
'payout': payout,
'winner_ratio': winner_ratio,
'total_entries': len(pos['all_entries']),
'up_entries': len(pos['UP']['entries']),
'down_entries': len(pos['DOWN']['entries']),
'up_invested': pos['UP']['total_invested'],
'down_invested': pos['DOWN']['total_invested'],
'up_shares': up_contracts,
'down_shares': down_contracts,
'duration': time.time() - pos['start_time'],
'close_time': time.time(),
'close_timestamp': time.strftime('%Y-%m-%d %H:%M:%S')
}
# ═══════════════════════════════════════════════════════════
# CRITICAL FIX: Log trade FIRST, then delete position!
# This prevents data loss if _log_trade() fails
# ═══════════════════════════════════════════════════════════
try:
# 1. Log trade to disk FIRST (most important!)
self._log_trade(trade)
# 2. Add to memory (safe even if disk write failed)
self.closed_trades.append(trade)
# 3. Mark market as closed to prevent re-entry
self.closed_markets.add(market_slug)
# 4. NOW we can safely delete the position
del self.positions[market_slug]
# 5. Clean up market stats
if market_slug in self.market_max_drawdown:
del self.market_max_drawdown[market_slug]
if market_slug in self.market_entries_count:
del self.market_entries_count[market_slug]
except Exception as e:
# CRITICAL: If logging failed, DO NOT delete position!
# Position will remain open and can be closed again
print(f"[TRADER] ⚠️ FAILED TO CLOSE MARKET {market_slug}: {e}")
print(f"[TRADER] ⚠️ Position kept open for retry!")
return None
# Print result
status = "🚨" if pnl < 0 else "✓"
print(f"[TRADER] {status} EARLY EXIT {market_slug} @ ${exit_price:.2f}: {pnl:+.2f} ({roi_pct:+.1f}%) | "
f"{trade['total_entries']} entries, ${total_cost:.0f} invested")
# 🔥 REAL SELL (if executor connected)
# 📊 Collecting real payouts for accurate PnL
real_payout = 0.0
real_sells_executed = False
if _order_executor and market_slug in _token_ids_cache:
token_ids = _token_ids_cache[market_slug]
# Sell both sides (UP and DOWN) using TRACKED contracts
for side in ['UP', 'DOWN']:
token_id = token_ids[side]
# Get tracked contract amount
side_contracts = up_contracts if side == 'UP' else down_contracts
# Skip if no contracts
if side_contracts <= 0:
continue
# Get bid price
bid = up_bid if side == 'UP' else down_bid
if bid is None:
# Fallback
bid = exit_price if side == 'UP' else (1 - exit_price)
result = _order_executor.sell_position(
market_slug=market_slug,
token_id=token_id,
side=side,
contracts=side_contracts, # TRACKED amount!
bid_price=bid
)
if result.success:
# Accumulating REAL payout
real_payout += result.total_spent_usd
real_sells_executed = True
elif not result.dry_run:
print(f"[TRADER] ⚠ Failed to sell {side}: {result.error}")
# ═══════════════════════════════════════════════════════════
# 📊 SLIPPAGE ANALYSIS: Expected vs Actual
# Compare estimated payout (by best BID) with real
# ═══════════════════════════════════════════════════════════
if real_sells_executed and real_payout > 0:
# Get orderbook snapshot (was captured BEFORE sell)
try:
if hasattr(self, '_last_orderbook_snapshot') and self._last_orderbook_snapshot:
snapshot = self._last_orderbook_snapshot
expected_payout = snapshot.get('expected_sale', {}).get('expected_payout_usd', payout)
expected_price = snapshot.get('expected_sale', {}).get('best_bid_price', exit_price)
# Calculate slippage
slippage_usd = real_payout - expected_payout
slippage_pct = (slippage_usd / expected_payout * 100) if expected_payout > 0 else 0
actual_avg_price = real_payout / (up_contracts + down_contracts) if (up_contracts + down_contracts) > 0 else 0
price_diff = actual_avg_price - expected_price
price_diff_pct = (price_diff / expected_price * 100) if expected_price > 0 else 0
print(f"\n{'='*80}")
print(f"[SLIPPAGE ANALYSIS] {self.coin.upper()} - {exit_reason}")
print(f"{'='*80}")
print(f"📊 EXPECTED (based on BID at trigger):")
print(f" Best BID price: ${expected_price:.4f}")
print(f" Expected payout: ${expected_payout:.2f}")
print(f" Expected PnL: ${pnl:.2f}")
print(f"")
print(f"💰 ACTUAL (from API response):")
print(f" Avg fill price: ${actual_avg_price:.4f}")
print(f" Actual payout: ${real_payout:.2f}")
print(f" Actual PnL: ${real_pnl:.2f}")
print(f"")
print(f"📉 SLIPPAGE:")
print(f" Payout difference: ${slippage_usd:+.2f} ({slippage_pct:+.1f}%)")
print(f" Price difference: ${price_diff:+.4f} ({price_diff_pct:+.1f}%)")
if slippage_usd < -1.0:
print(f" ⚠️ NEGATIVE SLIPPAGE > $1 - investigating...")
elif abs(slippage_usd) < 0.5:
print(f" ✅ Minimal slippage")
print(f"{'='*80}\n")
# Add to snapshot for logging
snapshot['actual_sale'] = {
'actual_payout': real_payout,
'actual_avg_price': actual_avg_price,
'actual_pnl': real_pnl,
'slippage_usd': slippage_usd,
'slippage_pct': slippage_pct,
'price_diff': price_diff,
'price_diff_pct': price_diff_pct
}
# Overwrite snapshot with actual data
self._log_exit_orderbook(snapshot)
except Exception as e:
print(f"[TRADER] ⚠ Slippage analysis error: {e}")
# ═══════════════════════════════════════════════════════════
# 📊 UPDATE TRADE RECORD with real data
# Recalculate PnL based on REAL payout from blockchain
# ═══════════════════════════════════════════════════════════
if real_sells_executed and real_payout > 0:
# Recalculate PnL with real payout
real_pnl = real_payout - total_cost
real_roi_pct = (real_pnl / total_cost * 100) if total_cost > 0 else 0
# Update trade record (returned and in memory)
trade['payout'] = real_payout
trade['pnl'] = real_pnl
trade['roi_pct'] = real_roi_pct
# IMPORTANT: Also update last element in closed_trades
# (which was added before sell)
if self.closed_trades and self.closed_trades[-1]['market_slug'] == market_slug:
self.closed_trades[-1]['payout'] = real_payout
self.closed_trades[-1]['pnl'] = real_pnl
self.closed_trades[-1]['roi_pct'] = real_roi_pct
# Log updated trade with real data
# (add second entry with updated=True flag for post-mortem analysis)
updated_trade = trade.copy()
updated_trade['updated'] = True
updated_trade['estimated_pnl'] = pnl
updated_trade['estimated_payout'] = payout
self._log_trade(updated_trade)
# Update capital with real PnL (instead of estimated)
self.current_capital = self.current_capital - pnl + real_pnl
print(f"[TRADER] 💰 Real payout: ${real_payout:.2f} (estimated: ${payout:.2f})")
if abs(real_pnl - pnl) > 0.5:
diff = real_pnl - pnl
print(f"[TRADER] ⚠️ PnL correction: {diff:+.2f} (real: {real_pnl:+.2f} vs estimated: {pnl:+.2f})")
# ═══════════════════════════════════════════════════════════
# 🔥 CRITICAL: Reset investment tracking for this market!
# Now we can trade new market without limits!
# ═══════════════════════════════════════════════════════════
try:
2026-07-27 19:22:46 +08:00
if _order_executor and hasattr(_order_executor, 'safety'):
_order_executor.safety.reset_market(market_slug)
2026-07-26 22:56:35 +08:00
except Exception as reset_err:
print(f"[TRADER] ⚠ Failed to reset market tracking: {reset_err}")
return trade
def _capture_orderbook_snapshot(self, market_slug: str, exit_reason: str,
up_bid: float, down_bid: float, up_ask: float, down_ask: float) -> Dict:
"""
Capture full orderbook snapshot for exit analysis
Returns dict with position + orderbook data
"""
pos = self.positions.get(market_slug, {})
# Determine which side we're selling
up_shares = pos.get('UP', {}).get('total_shares', 0)
down_shares = pos.get('DOWN', {}).get('total_shares', 0)
if up_shares > down_shares:
our_side = 'UP'
sell_contracts = up_shares
sell_bid_price = up_bid
elif down_shares > 0:
our_side = 'DOWN'
sell_contracts = down_shares
sell_bid_price = down_bid
else:
our_side = None
sell_contracts = 0
sell_bid_price = 0
total_invested = pos.get('UP', {}).get('total_invested', 0) + pos.get('DOWN', {}).get('total_invested', 0)
# Get full orderbook from data_feed
up_bids_full = []
down_bids_full = []
up_asks_full = []
down_asks_full = []
if _data_feed:
market_state = _data_feed.get_state(self.coin)
up_bids_full = market_state.get('up_bids_full', [])
down_bids_full = market_state.get('down_bids_full', [])
up_asks_full = market_state.get('up_asks_full', [])
down_asks_full = market_state.get('down_asks_full', [])
snapshot = {
'timestamp': time.time(),
'datetime': time.strftime('%Y-%m-%d %H:%M:%S'),
'coin': self.coin,
'market_slug': market_slug,
'exit_reason': exit_reason,
'position': {
'up_shares': up_shares,
'down_shares': down_shares,
'up_invested': pos.get('UP', {}).get('total_invested', 0),
'down_invested': pos.get('DOWN', {}).get('total_invested', 0),
'total_invested': total_invested,
'our_side': our_side
},
'orderbook': {
'UP': {
'best_bid': up_bid,
'best_ask': up_ask,
'spread': up_ask - up_bid if (up_ask and up_bid) else 0,
'bids_top5': [{'price': p, 'size': s} for p, s in up_bids_full[:5]],
'asks_top1': [{'price': p, 'size': s} for p, s in up_asks_full[:1]]
},
'DOWN': {
'best_bid': down_bid,
'best_ask': down_ask,
'spread': down_ask - down_bid if (down_ask and down_bid) else 0,
'bids_top5': [{'price': p, 'size': s} for p, s in down_bids_full[:5]],
'asks_top1': [{'price': p, 'size': s} for p, s in down_asks_full[:1]]
}
},
'expected_sale': {
'side': our_side,
'contracts': sell_contracts,
'best_bid_price': sell_bid_price,
'expected_payout_usd': sell_contracts * sell_bid_price if sell_bid_price else 0,
'invested_usd': total_invested,
'expected_loss_usd': (sell_contracts * sell_bid_price - total_invested) if sell_bid_price else -total_invested
}
}
return snapshot
def _log_exit_orderbook(self, snapshot: Dict):
"""Write orderbook snapshot to log file for analysis"""
import os
log_dir = f"logs/{self.strategy_name}"
os.makedirs(log_dir, exist_ok=True)
log_file = f"{log_dir}/exit_orderbooks.jsonl"
with open(log_file, 'a') as f:
f.write(json.dumps(snapshot) + '\n')
# Print summary to console
print(f"\n{'='*80}")
print(f"[EXIT ORDERBOOK] {snapshot['coin'].upper()} - {snapshot['exit_reason']}")
print(f"Market: {snapshot['market_slug']}")
print(f"Our side: {snapshot['position']['our_side']}")
print(f"Invested: ${snapshot['position']['total_invested']:.2f}")
print(f"Best bid (sell price): {snapshot['expected_sale']['best_bid_price']:.4f}")
print(f"Expected payout: ${snapshot['expected_sale']['expected_payout_usd']:.2f}")
print(f"Expected loss: ${snapshot['expected_sale']['expected_loss_usd']:.2f}")
print(f"UP: BID={snapshot['orderbook']['UP']['best_bid']:.4f} ASK={snapshot['orderbook']['UP']['best_ask']:.4f} SPREAD={snapshot['orderbook']['UP']['spread']:.4f}")
print(f"DOWN: BID={snapshot['orderbook']['DOWN']['best_bid']:.4f} ASK={snapshot['orderbook']['DOWN']['best_ask']:.4f} SPREAD={snapshot['orderbook']['DOWN']['spread']:.4f}")
# Print full orderbook of selling side
our_side = snapshot['position']['our_side']
if our_side:
print(f"\n{our_side} Orderbook (we're selling here):")
ob = snapshot['orderbook'][our_side]
print(f" Asks (top 1):")
for level in ob['asks_top1']:
print(f" ${level['price']:.4f} × {level['size']:.2f}")
print(f" Bids (top 5):")
for level in ob['bids_top5']:
print(f" ${level['price']:.4f} × {level['size']:.2f}")
print(f"{'='*80}\n")
def get_market_stats(self, market_slug: str, up_current: float = 0.5, down_current: float = 0.5) -> Optional[Dict]:
"""
Get statistics for a specific market including unrealized PnL
✅ USES REAL DATA from trader.positions (updated via REST API takingAmount)!
"""
if market_slug not in self.positions:
return None
pos = self.positions[market_slug]
total_entries = len(pos['all_entries'])
# ✅ USE REAL DATA from trader.positions (updated via REST API)
total_invested = pos['UP']['total_invested'] + pos['DOWN']['total_invested']
up_shares = pos['UP']['total_shares']
down_shares = pos['DOWN']['total_shares']
up_invested = pos['UP']['total_invested']
down_invested = pos['DOWN']['total_invested']
up_avg_price = (pos['UP']['total_invested'] / pos['UP']['total_shares']) if pos['UP']['total_shares'] > 0 else 0
down_avg_price = (pos['DOWN']['total_invested'] / pos['DOWN']['total_shares']) if pos['DOWN']['total_shares'] > 0 else 0
# Calculate unrealized PnL using current prices
up_value = pos['UP']['total_shares'] * up_current
down_value = pos['DOWN']['total_shares'] * down_current
total_value = up_value + down_value
unrealized_pnl = total_value - total_invested
up_entries = len(pos['UP']['entries'])
down_entries = len(pos['DOWN']['entries'])
total_shares = up_shares + down_shares
up_ratio = (up_shares / total_shares * 100) if total_shares > 0 else 0
down_ratio = (down_shares / total_shares * 100) if total_shares > 0 else 0
return {
'total_entries': total_entries,
'total_invested': total_invested,
'total_cost': total_invested, # Alias for compatibility
'avg_per_entry': total_invested / total_entries if total_entries > 0 else 0,
'up_entries': up_entries,
'down_entries': down_entries,
'up_invested': up_invested, # ✅ REAL data
'down_invested': down_invested, # ✅ REAL data
'up_shares': up_shares, # ✅ REAL data
'down_shares': down_shares, # ✅ REAL data
'up_avg_price': up_avg_price,
'down_avg_price': down_avg_price,
'up_ratio': up_ratio,
'down_ratio': down_ratio,
'unrealized_pnl': unrealized_pnl, # ✅ REAL PnL from WebSocket!
'exposure_pct': (total_invested / self.current_capital * 100) if self.current_capital > 0 else 0.0
}
def get_performance_stats(self) -> Dict:
"""Get overall performance statistics"""
total_trades = len(self.closed_trades)
wins = sum(1 for t in self.closed_trades if t['pnl'] > 0)
losses = total_trades - wins
win_rate = (wins / total_trades * 100) if total_trades > 0 else 0
total_pnl = sum(t['pnl'] for t in self.closed_trades)
avg_pnl = total_pnl / total_trades if total_trades > 0 else 0
winning_trades = [t for t in self.closed_trades if t['pnl'] > 0]
losing_trades = [t for t in self.closed_trades if t['pnl'] <= 0]
best_win = max(winning_trades, key=lambda t: t['pnl']) if winning_trades else None
worst_loss = min(losing_trades, key=lambda t: t['pnl']) if losing_trades else None
total_wins = sum(t['pnl'] for t in winning_trades)
total_losses = abs(sum(t['pnl'] for t in losing_trades))
profit_factor = (total_wins / total_losses) if total_losses > 0 else 0
avg_entries = sum(t.get('total_entries', 0) for t in self.closed_trades) / total_trades if total_trades > 0 else 0
avg_invested = sum(t.get('total_cost', 0) for t in self.closed_trades) / total_trades if total_trades > 0 else 0
return {
'total_trades': total_trades,
'wins': wins,
'losses': losses,
'win_rate': win_rate,
'total_pnl': total_pnl,
'avg_pnl': avg_pnl,
'best_win': best_win,
'worst_loss': worst_loss,
'profit_factor': profit_factor,
'avg_entries': avg_entries,
'avg_invested': avg_invested
}
def _update_market_stats(self, market_slug: str):
"""Update market statistics after entry"""
# Update entries count
if market_slug not in self.market_entries_count:
self.market_entries_count[market_slug] = 0
self.market_entries_count[market_slug] += 1
# Initialize max drawdown if needed
if market_slug not in self.market_max_drawdown:
self.market_max_drawdown[market_slug] = 0.0
def update_market_drawdown(self, market_slug: str, unrealized_pnl: float):
"""Update max drawdown for market if current is worse"""
if market_slug not in self.market_max_drawdown:
self.market_max_drawdown[market_slug] = 0.0
if unrealized_pnl < self.market_max_drawdown[market_slug]:
self.market_max_drawdown[market_slug] = unrealized_pnl
def get_market_detailed_stats(self, market_slug: str, up_ask: float = 0.5, down_ask: float = 0.5) -> Optional[Dict]:
"""
Get detailed statistics for a market
Args:
market_slug: Market identifier
up_ask: Current UP ask price
down_ask: Current DOWN ask price
Returns:
Dict with detailed stats or None
"""
if market_slug not in self.positions:
return None
pos = self.positions[market_slug]
up_shares = pos['UP']['total_shares']
down_shares = pos['DOWN']['total_shares']
up_invested = pos['UP']['total_invested']
down_invested = pos['DOWN']['total_invested']
total_invested = up_invested + down_invested
# Current value (unrealized)
current_value = (up_shares * up_ask) + (down_shares * down_ask)
unrealized_pnl = current_value - total_invested
unrealized_pct = (unrealized_pnl / total_invested * 100) if total_invested > 0 else 0
# ═══════════════════════════════════════════════════════════
# 🚨 CHECK STOP-LOSS RIGHT HERE (where PnL is calculated!)
# ═══════════════════════════════════════════════════════════
stop_loss_triggered = False
stop_loss_threshold = None
stop_loss_type = None
# Get coin from market_slug (e.g., "btc-updown-15m-1768060800" -> "btc")
coin = market_slug.split('-')[0] if '-' in market_slug else ''
# Check if we have config for stop-loss
if self.config and coin and total_invested > 0:
sl_config = self.config.get('exit', {}).get('stop_loss', {}).get('per_coin', {}).get(coin, {})
sl_enabled = sl_config.get('enabled', False)
sl_type = sl_config.get('type', 'none')
sl_value = sl_config.get('value', None)
if sl_enabled and sl_value is not None:
if sl_type == 'fixed':
# Fixed dollar amount (e.g., -$10)
stop_loss_threshold = sl_value
stop_loss_triggered = unrealized_pnl <= stop_loss_threshold
stop_loss_type = 'fixed'
elif sl_type == 'percent':
# Percentage of invested capital (e.g., -15%)
stop_loss_threshold = total_invested * (sl_value / 100.0)
stop_loss_triggered = unrealized_pnl <= stop_loss_threshold
stop_loss_type = 'percent'
# ═══════════════════════════════════════════════════════════
# 🚨 CHECK FLIP-STOP (price reversal protection)
# ═══════════════════════════════════════════════════════════
flip_stop_triggered = False
flip_stop_price = None
if self.config and coin and (up_shares > 0 or down_shares > 0):
flip_cfg = self.config.get('exit', {}).get('flip_stop', {})
flip_stop_price = flip_cfg.get('price_threshold', 0.48)
# Determine our side
our_side = 'UP' if up_shares > down_shares else 'DOWN'
our_price = up_ask if our_side == 'UP' else down_ask
# Check if our side price dropped too low
if our_price <= flip_stop_price:
flip_stop_triggered = True
print(f"[FLIP-STOP] 🚨 {coin.upper()} {our_side} @ ${our_price:.4f} <= ${flip_stop_price:.4f} TRIGGERED!")
else:
# Log warning if price is getting close to flip-stop (within 25%)
if our_price < flip_stop_price * 1.25:
print(f"[FLIP-STOP] ⚠️ {coin.upper()} {our_side} @ ${our_price:.4f} close to ${flip_stop_price:.4f}")
# Update drawdown with current unrealized PnL
self.update_market_drawdown(market_slug, unrealized_pnl)
# Max drawdown
max_dd = self.market_max_drawdown.get(market_slug, 0.0)
max_dd_pct = (max_dd / total_invested * 100) if total_invested > 0 else 0
# Average entry prices
avg_up_price = up_invested / up_shares if up_shares > 0 else 0
avg_down_price = down_invested / down_shares if down_shares > 0 else 0
# Entries count
entries_count = self.market_entries_count.get(market_slug, len(pos['all_entries']))
return {
'up_shares': up_shares,
'down_shares': down_shares,
'up_invested': up_invested,
'down_invested': down_invested,
'total_invested': total_invested,
'unrealized_pnl': unrealized_pnl,
'unrealized_pct': unrealized_pct,
'max_drawdown': max_dd,
'max_drawdown_pct': max_dd_pct,
'avg_up_price': avg_up_price,
'avg_down_price': avg_down_price,
'entries_count': entries_count,
'stop_loss_triggered': stop_loss_triggered,
'stop_loss_threshold': stop_loss_threshold,
'stop_loss_type': stop_loss_type,
'flip_stop_triggered': flip_stop_triggered,
'flip_stop_price': flip_stop_price
}
def _log_trade(self, trade: Dict):
"""
Log trade to file with maximum fault tolerance
CRITICAL: This function MUST succeed or raise exception!
If it fails silently, we lose trade data!
"""
try:
# Ensure directory exists
self.trades_file.parent.mkdir(parents=True, exist_ok=True)
# Write to file with explicit flush
with open(self.trades_file, 'a') as f:
f.write(json.dumps(trade) + '\n')
f.flush() # Force write to disk immediately
except PermissionError as e:
print(f"[TRADER] ⚠️ PERMISSION ERROR logging trade: {e}")
print(f"[TRADER] ⚠️ Trade data: {trade}")
print(f"[TRADER] ⚠️ File: {self.trades_file}")
raise # Re-raise to prevent position deletion
except OSError as e:
print(f"[TRADER] ⚠️ DISK ERROR logging trade: {e}")
print(f"[TRADER] ⚠️ Trade data: {trade}")
print(f"[TRADER] ⚠️ Check disk space: df -h")
raise # Re-raise to prevent position deletion
except Exception as e:
print(f"[TRADER] ⚠️ UNKNOWN ERROR logging trade: {e}")
print(f"[TRADER] ⚠️ Trade data: {trade}")
import traceback
traceback.print_exc()
raise # Re-raise to prevent position deletion
def save_session(self):
"""Save current session state"""
try:
session = {
'starting_capital': self.starting_capital,
'current_capital': self.current_capital,
'total_pnl': self.current_capital - self.starting_capital,
'roi_pct': ((self.current_capital / self.starting_capital) - 1) * 100,
'open_positions': len(self.positions),
'closed_trades': len(self.closed_trades),
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S')
}
with open(self.session_file, 'w') as f:
json.dump(session, f, indent=2)
except Exception as e:
print(f"[TRADER] Error saving session: {e}")
def log_entry_detailed(self, market_slug: str, side: str, contracts: int,
price: float, up_ask: float, down_ask: float,
winner_ratio: float, is_recovery: bool,
entry_reason: str, seconds_till_end: int,
time_from_start: int):
"""
Log detailed entry for backtesting analysis
Args:
market_slug: Full market slug
side: 'UP' or 'DOWN'
contracts: Number of contracts
price: Entry price
up_ask: Current UP ask price
down_ask: Current DOWN ask price
winner_ratio: Current winner ratio (0.0-1.0)
is_recovery: Is this a recovery entry after WR < 40%?
entry_reason: 'normal' or 'recovery'
seconds_till_end: Seconds until market end
time_from_start: Seconds from market start
"""
import os
# Create detailed logs directory
detailed_dir = str(self.log_dir).replace('/logs/', '/logs_detailed/')
Path(detailed_dir).mkdir(parents=True, exist_ok=True)
# Get position data
if market_slug not in self.positions:
return
pos = self.positions[market_slug]
# Calculate current metrics
up_contracts = pos['UP']['total_shares']
down_contracts = pos['DOWN']['total_shares']
up_invested = pos['UP']['total_invested']
down_invested = pos['DOWN']['total_invested']
total_invested = up_invested + down_invested
total_contracts = up_contracts + down_contracts
entries_count = len(pos['all_entries'])
# Calculate CORRECT unrealized PnL based on current market prices
current_value = (up_contracts * up_ask) + (down_contracts * down_ask)
unrealized_pnl = current_value - total_invested
unrealized_pnl_pct = (unrealized_pnl / total_invested * 100) if total_invested > 0 else 0
# Update max drawdown with current unrealized PnL BEFORE reading it
self.update_market_drawdown(market_slug, unrealized_pnl)
# Calculate PnL scenarios if market resolves
if_up_wins = (up_contracts * 1.0) - total_invested
if_down_wins = (down_contracts * 1.0) - total_invested
# Average prices
avg_up_price = (up_invested / up_contracts) if up_contracts > 0 else 0
avg_down_price = (down_invested / down_contracts) if down_contracts > 0 else 0
# Get max drawdown for this market (after updating it above)
max_dd = self.market_max_drawdown.get(market_slug, 0.0)
max_dd_pct = (max_dd / total_invested * 100) if total_invested > 0 else 0
# Build entry data
entry_data = {
"timestamp": int(time.time()),
"market_slug": market_slug,
"seconds_till_end": seconds_till_end,
"time_from_start": time_from_start,
"market_prices": {
"up_ask": round(up_ask, 3),
"down_ask": round(down_ask, 3),
"confidence": round(abs(down_ask - up_ask), 3)
},
"entry": {
"side": side,
"contracts": contracts,
"price": round(price, 3),
"cost": round(contracts * price, 2)
},
"position_after": {
"up_contracts": int(up_contracts),
"down_contracts": int(down_contracts),
"up_invested": round(up_invested, 2),
"down_invested": round(down_invested, 2),
"total_invested": round(total_invested, 2),
"total_contracts": int(total_contracts),
"entries_count": entries_count
},
"pnl_metrics": {
"unrealized_pnl": round(unrealized_pnl, 2),
"unrealized_pnl_pct": round(unrealized_pnl_pct, 2),
"max_drawdown": round(max_dd, 2),
"max_drawdown_pct": round(max_dd_pct, 2),
"if_up_wins": round(if_up_wins, 2),
"if_down_wins": round(if_down_wins, 2),
"avg_up_price": round(avg_up_price, 3),
"avg_down_price": round(avg_down_price, 3)
},
"strategy_state": {
"winner_ratio": round(winner_ratio, 3),
"is_recovery": is_recovery,
"entry_reason": entry_reason
}
}
# Filename based on market slug
filename = f"{market_slug}_entries.jsonl"
filepath = os.path.join(detailed_dir, filename)
# Append entry
with open(filepath, 'a') as f:
f.write(json.dumps(entry_data) + '\n')