diff --git a/bot_brain.py b/bot_brain.py index ff18b7f..e2992e5 100644 --- a/bot_brain.py +++ b/bot_brain.py @@ -3,7 +3,7 @@ import sys import json from loguru import logger -from database import get_db_session, Position, init_db +from database import get_db_session, Position, TradeHistory, init_db from sqlalchemy import select from core.exchange_client import ExchangeClient from core.executor import TradeExecutor @@ -18,6 +18,77 @@ logger.error("config.json not found! Using defaults.") CONFIG = {"strategy": {}, "system": {}} +class MoonBagBot: + def __init__(self): + self.exchange = ExchangeClient() + strategy = CONFIG.get("strategy", {}) + self.target_multiplier = strategy.get("moonbag_target_multiplier", 2.0) + self.initial_sell_percentage = strategy.get("initial_sell_percentage", 0.5) + self.trailing_stop_percentage = strategy.get("trailing_stop_percentage", 0.05) + + async def sync_wallet(self, db): + """ + Sync wallet balances into tracked positions. + """ + balances = await self.exchange.fetch_balance() + for symbol, amount in balances.items(): + stmt = select(Position).where(Position.symbol == symbol) + result = await db.execute(stmt) + pos = result.scalar_one_or_none() + + if not pos: + price = await self.exchange.get_current_price(symbol) + pos = Position( + symbol=symbol, + entry_price=price, + current_amount=amount, + status="active", + highest_price_seen=price, + ) + db.add(pos) + logger.info(f"New Position Tracked: {symbol} @ {price}") + else: + if pos.current_amount != amount: + pos.current_amount = amount + + await db.commit() + + async def check_strategy(self, db): + """ + Apply the MoonBag strategy to active positions. + """ + stmt = select(Position).where(Position.status == "active") + result = await db.execute(stmt) + positions = result.scalars().all() + + for pos in positions: + current_price = await self.exchange.get_current_price(pos.symbol) + target_price = pos.entry_price * self.target_multiplier + + if current_price >= target_price: + amount_to_sell = pos.current_amount * self.initial_sell_percentage + await self.exchange.execute_sell_order(pos.symbol, amount_to_sell) + + trade = TradeHistory( + symbol=pos.symbol, + amount=amount_to_sell, + price=current_price, + type="sell", + ) + db.add(trade) + + pos.status = "moonbag_secured" + pos.is_initial_investment_recovered = True + pos.current_amount -= amount_to_sell + pos.trailing_stop_price = current_price * (1 - self.trailing_stop_percentage) + if pos.highest_price_seen is None or current_price > pos.highest_price_seen: + pos.highest_price_seen = current_price + + logger.success(f"MoonBag Secured for {pos.symbol}. Remaining: {pos.current_amount}") + + await db.commit() + + class PredatorBot: def __init__(self): self.running = False diff --git a/core/exchange_client.py b/core/exchange_client.py index e48d6ab..940fba3 100644 --- a/core/exchange_client.py +++ b/core/exchange_client.py @@ -12,6 +12,7 @@ def __init__(self, exchange_id: str = 'binance'): self.exchange_id = exchange_id self.api_key = os.getenv("API_KEY") self.api_secret = os.getenv("API_SECRET") + self.quote_currency = os.getenv("QUOTE_CURRENCY", "USDT") self.client = getattr(ccxt, exchange_id)({ 'apiKey': self.api_key, 'secret': self.api_secret, @@ -22,6 +23,12 @@ def __init__(self, exchange_id: str = 'binance'): }) self.logger = logging.getLogger("ExchangeClient") + def normalize_symbol(self, symbol: str) -> str: + """ + Normalize symbols to include a quote currency when missing. + """ + return f"{symbol}/{self.quote_currency}" if '/' not in symbol else symbol + async def fetch_tickers(self, symbols: List[str]) -> Dict[str, float]: """ Fetch multiple tickers at once if supported, or parallelize. @@ -30,39 +37,39 @@ async def fetch_tickers(self, symbols: List[str]) -> Dict[str, float]: if not symbols: return {} - try: - # CCXT fetch_tickers support varies. - # If supported, it's 1 call. - # We assume most major exchanges support it. - # Convert 'BTC' to 'BTC/USDT' roughly for crypto - pairs = [f"{s}/USDT" if '/' not in s else s for s in symbols] - - # Check capabilities - if self.client.has['fetchTickers']: + # CCXT fetch_tickers support varies. If supported, it's 1 call. + pairs = [self.normalize_symbol(s) for s in symbols] + results = {} + + if self.client.has.get('fetchTickers'): + try: tickers = await self.client.fetch_tickers(pairs) # Map back to simple symbol if needed or just return last price - results = {} for pair, data in tickers.items(): + if not isinstance(data, dict): + continue + last = data.get('last') or data.get('close') + if last is None: + continue # extract 'BTC' from 'BTC/USDT' sym = pair.split('/')[0] if '/' in pair else pair - results[sym] = data['last'] - return results - else: - # Fallback to parallel fetch - # Semaphore to avoid rate limits - results = {} - # TODO: Implement parallel fetch with semaphore - # For now sequential fallback logic (simplified) - for s in symbols: - results[s] = await self.get_current_price(s) - return results + results[sym] = last + if results: + return results + except Exception as e: + self.logger.error(f"Batch Tick Error: {e}") - except Exception as e: - self.logger.error(f"Batch Tick Error: {e}") - return {} + # Fallback to sequential fetch if batch is unsupported or empty. + for s in symbols: + try: + results[s] = await self.get_current_price(s) + except Exception as e: + self.logger.error(f"Tick Error {s}: {e}") + + return results async def get_exchange_id(self): - return self.client.idclose() + return self.client.id async def close(self): await self.client.close() @@ -74,13 +81,34 @@ async def fetch_balance(self) -> Dict[str, float]: """ try: balance = await self.client.fetch_balance() - # Filter for non-zero free balances, exclude USDT/USD usually if not trading against it - # But here we just want non-zero assets - non_zero = { - k: v['free'] - for k, v in balance.items() - if v['free'] > 0 and k not in ['USDT', 'USD', 'USDC'] # simplified exclusion - } + # Filter for non-zero free balances, exclude stablecoins. + # CCXT returns both aggregate keys and per-currency dicts. + excluded = {"USDT", "USD", "USDC"} + non_zero: Dict[str, float] = {} + + for symbol, data in balance.items(): + if symbol in {"info", "free", "used", "total"}: + continue + if not isinstance(data, dict): + continue + free = data.get("free") + try: + free_amount = float(free) + except (TypeError, ValueError): + continue + if free_amount > 0 and symbol not in excluded: + non_zero[symbol] = free_amount + + # Fallback to the aggregate free dict if currency entries are missing. + if not non_zero and isinstance(balance.get("free"), dict): + for symbol, free in balance["free"].items(): + try: + free_amount = float(free) + except (TypeError, ValueError): + continue + if free_amount > 0 and symbol not in excluded: + non_zero[symbol] = free_amount + return non_zero except Exception as e: self.logger.error(f"Error fetching balance: {e}") @@ -90,7 +118,7 @@ async def get_current_price(self, symbol: str) -> float: """ Get current price for a symbol. Assumes USDT pair if no slash. """ - pair = f"{symbol}/USDT" if '/' not in symbol else symbol + pair = self.normalize_symbol(symbol) try: ticker = await self.client.fetch_ticker(pair) return ticker['last'] @@ -103,7 +131,7 @@ async def execute_sell_order(self, symbol: str, amount: float): """ Market sell 50% (or specified amount). """ - pair = f"{symbol}/USDT" + pair = self.normalize_symbol(symbol) try: # Fetch market structure to check min notional if possible, but keeping simple for MVP self.logger.info(f"EXECUTING SELL: {symbol} amount={amount}") diff --git a/core/executor.py b/core/executor.py index f106bc7..797e651 100644 --- a/core/executor.py +++ b/core/executor.py @@ -15,8 +15,17 @@ class TradeExecutor: def __init__(self, exchange_client: ExchangeClient, config: dict): self.exchange = exchange_client - self.slippage_tolerance = config['strategy']['slippage_tolerance_pct'] - self.max_chunks = config['strategy']['max_slippage_retry_chunks'] + strategy = config.get('strategy', {}) + try: + self.slippage_tolerance = float(strategy.get('slippage_tolerance_pct', 0.005)) + except (TypeError, ValueError): + self.slippage_tolerance = 0.005 + + try: + max_chunks = int(strategy.get('max_slippage_retry_chunks', 3)) + except (TypeError, ValueError): + max_chunks = 3 + self.max_chunks = max(1, max_chunks) async def calculate_breakeven_entry(self, symbol: str, execution_price: float, side: str = 'buy') -> float: """ @@ -26,11 +35,16 @@ async def calculate_breakeven_entry(self, symbol: str, execution_price: float, s # most exchanges ~0.1% fee_rate = 0.001 try: - # Attempt to get actual markets if loaded - if self.exchange.client.markets: - market = self.exchange.client.markets.get(symbol) - if market and 'taker' in market: - fee_rate = market['taker'] + # Attempt to get actual markets if loaded + markets = self.exchange.client.markets + if markets: + market = markets.get(symbol) + if not market: + market = markets.get(self.exchange.normalize_symbol(symbol)) + if market: + taker = market.get('taker') + if taker is not None: + fee_rate = taker except Exception: pass @@ -45,8 +59,10 @@ async def get_liquidity_adjusted_price(self, symbol: str, amount: float, side: s """ # Fetch top of book - usually enough for small retail. # For "Predator", we fetch depth. - ob = await self.exchange.client.fetch_order_book(symbol, limit=20) - orders = ob['bids'] if side == 'sell' else ob['asks'] + pair = self.exchange.normalize_symbol(symbol) + ob = await self.exchange.client.fetch_order_book(pair, limit=20) + orders = ob.get('bids') if side == 'sell' else ob.get('asks') + orders = orders or [] filled = 0.0 weighted_sum = 0.0 @@ -59,10 +75,11 @@ async def get_liquidity_adjusted_price(self, symbol: str, amount: float, side: s if filled >= amount: break + if filled == 0: + return await self.exchange.get_current_price(symbol) if filled < amount: - # Not enough liquidity in top 20! - # Fallback to last price or risky. - return orders[-1][0] + # Not enough liquidity in top 20; use average of available depth. + return weighted_sum / filled return weighted_sum / amount @@ -70,14 +87,28 @@ async def execute_smart_sell(self, symbol: str, total_amount: float) -> List[Dic """ Liquidity-aware sell. Splits order if slippage is too high. """ + if total_amount <= 0: + logger.warning(f"Executor: Skipping sell for {symbol} amount={total_amount}") + return [] logger.info(f"Executor: Analyzing sell for {total_amount} {symbol}...") - ticker = await self.exchange.client.fetch_ticker(symbol) - last_price = ticker['last'] + pair = self.exchange.normalize_symbol(symbol) + ticker = await self.exchange.client.fetch_ticker(pair) + last_price = ticker.get('last') + if not last_price or last_price <= 0: + last_price = await self.exchange.get_current_price(symbol) + if not last_price or last_price <= 0: + logger.warning(f"Executor: Missing last price for {symbol}, executing without slippage check.") + order = await self.exchange.execute_sell_order(symbol, total_amount) + return [order] # Check impact avg_price = await self.get_liquidity_adjusted_price(symbol, total_amount, 'sell') - slippage = (last_price - avg_price) / last_price + if not avg_price or avg_price <= 0: + logger.warning(f"Executor: Missing liquidity price for {symbol}, executing without slippage check.") + order = await self.exchange.execute_sell_order(symbol, total_amount) + return [order] + slippage = max(0.0, (last_price - avg_price) / last_price) logger.info(f"Executor: Est. Slippage for {symbol}: {slippage:.4%}") diff --git a/core/strategist.py b/core/strategist.py index d9d913a..39bbd55 100644 --- a/core/strategist.py +++ b/core/strategist.py @@ -7,7 +7,7 @@ 2. Volatility Guard: Prevent selling into fakeout wicks. 3. Hedged Trailing Stop: Protect the remaining 50% 'free roll'. """ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from loguru import logger from database import Position from core.executor import TradeExecutor @@ -16,7 +16,14 @@ class Strategist: def __init__(self, executor: TradeExecutor, config: dict): self.executor = executor - self.config = config['strategy'] + defaults = { + "moonbag_target_multiplier": 2.0, + "initial_sell_percentage": 0.5, + "trailing_stop_percentage": 0.05, + "volatility_guard_window_seconds": 5, + } + strategy = config.get('strategy', {}) + self.config = {**defaults, **strategy} self.target_multiplier = self.config['moonbag_target_multiplier'] self.trailing_stop_pct = self.config['trailing_stop_percentage'] self.volatility_window = self.config['volatility_guard_window_seconds'] @@ -88,7 +95,7 @@ def _check_volatility_pass(self, symbol: str, price: float) -> bool: """ Returns True if price has been stable/high for X seconds. """ - now = datetime.utcnow() + now = datetime.now(timezone.utc) if symbol not in self.pump_candidates: self.pump_candidates[symbol] = (now, price) logger.info(f"Volatility Guard: {symbol} hit target. Waiting {self.volatility_window}s...") diff --git a/database.py b/database.py index c0a5e84..78d2363 100644 --- a/database.py +++ b/database.py @@ -1,6 +1,6 @@ import os import asyncio -from datetime import datetime +from datetime import datetime, timezone from typing import Optional from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker @@ -13,6 +13,9 @@ DB_URL = os.getenv("DB_URL", "sqlite+aiosqlite:///./tradebot.db") engine = create_async_engine(DB_URL, echo=True) + +def utc_now() -> datetime: + return datetime.now(timezone.utc) AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) class Base(DeclarativeBase): @@ -31,7 +34,11 @@ class Position(Base): highest_price_seen: Mapped[Optional[float]] = mapped_column(Float, nullable=True) trailing_stop_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True) - last_updated: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + last_updated: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utc_now, + onupdate=utc_now, + ) class TradeHistory(Base): __tablename__ = "trade_history" @@ -42,7 +49,7 @@ class TradeHistory(Base): price: Mapped[float] = mapped_column(Float) type: Mapped[str] = mapped_column(String) # sell, buy profit_pnl: Mapped[Optional[float]] = mapped_column(Float, nullable=True) - timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) class ExecutionLog(Base): __tablename__ = "execution_logs" @@ -53,7 +60,7 @@ class ExecutionLog(Base): status: Mapped[str] = mapped_column(String) # success, failed, split slippage: Mapped[Optional[float]] = mapped_column(Float, nullable=True) error_message: Mapped[Optional[str]] = mapped_column(String, nullable=True) - timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) async def init_db(): async with engine.begin() as conn: diff --git a/main.py b/main.py index d3818d9..4d4aebf 100644 --- a/main.py +++ b/main.py @@ -3,7 +3,7 @@ from sqlalchemy import select, desc from typing import List, Optional from pydantic import BaseModel -from datetime import datetime +from datetime import datetime, timezone from database import get_db_session, Position, TradeHistory import os @@ -34,7 +34,7 @@ class SettingsUpdate(BaseModel): @app.get("/health") async def health_check(): - return {"status": "ok", "timestamp": datetime.utcnow()} + return {"status": "ok", "timestamp": datetime.now(timezone.utc)} @app.get("/portfolio", response_model=List[PositionRead]) async def get_portfolio(db: AsyncSession = Depends(get_db_session)):