feat: AI agent, signal engine, surge detector, portfolio simulator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
114
engine/portfolio.py
Normal file
114
engine/portfolio.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from config import MAX_POSITIONS, MIN_POSITION_USD, STOP_LOSS_PCT, TAKE_PROFIT_1_PCT, TAKE_PROFIT_2_PCT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class PortfolioManager:
|
||||
def __init__(self, initial_capital: float = 200.0):
|
||||
self.initial_capital = initial_capital
|
||||
self.cash = initial_capital
|
||||
self.positions: dict[str, dict] = {}
|
||||
self.trades: list[dict] = []
|
||||
|
||||
def buy(self, symbol: str, price: float, score: float) -> bool:
|
||||
if symbol in self.positions:
|
||||
return False
|
||||
if len(self.positions) >= MAX_POSITIONS:
|
||||
return False
|
||||
amount = self._position_size(score)
|
||||
if amount < MIN_POSITION_USD:
|
||||
return False
|
||||
if amount > self.cash:
|
||||
amount = self.cash
|
||||
if amount < MIN_POSITION_USD:
|
||||
return False
|
||||
quantity = amount / price
|
||||
self.cash -= amount
|
||||
self.positions[symbol] = {
|
||||
"entry_price": price, "quantity": quantity,
|
||||
"invested_usd": amount, "tp1_hit": False,
|
||||
"opened_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
self.trades.append({
|
||||
"coin": symbol, "side": "BUY", "price": price,
|
||||
"quantity": quantity, "amount_usd": amount,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(), "reason": "signal",
|
||||
})
|
||||
return True
|
||||
|
||||
def sell(self, symbol: str, price: float, reason: str = "signal", partial: float = 1.0):
|
||||
if symbol not in self.positions:
|
||||
return
|
||||
pos = self.positions[symbol]
|
||||
sell_qty = pos["quantity"] * partial
|
||||
sell_usd = sell_qty * price
|
||||
self.cash += sell_usd
|
||||
self.trades.append({
|
||||
"coin": symbol, "side": "SELL", "price": price,
|
||||
"quantity": sell_qty, "amount_usd": sell_usd,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(), "reason": reason,
|
||||
})
|
||||
if partial >= 1.0:
|
||||
del self.positions[symbol]
|
||||
else:
|
||||
pos["quantity"] -= sell_qty
|
||||
|
||||
def check_exit(self, symbol: str, current_price: float):
|
||||
if symbol not in self.positions:
|
||||
return
|
||||
pos = self.positions[symbol]
|
||||
entry = pos["entry_price"]
|
||||
change_pct = (current_price - entry) / entry
|
||||
if change_pct <= STOP_LOSS_PCT:
|
||||
self.sell(symbol, current_price, reason="stop-loss")
|
||||
return
|
||||
if change_pct >= TAKE_PROFIT_2_PCT:
|
||||
self.sell(symbol, current_price, reason="take-profit-2")
|
||||
return
|
||||
if change_pct >= TAKE_PROFIT_1_PCT and not pos["tp1_hit"]:
|
||||
pos["tp1_hit"] = True
|
||||
self.sell(symbol, current_price, reason="take-profit-1", partial=0.5)
|
||||
|
||||
def _position_size(self, score: float) -> float:
|
||||
if score >= 90:
|
||||
pct = 0.30
|
||||
elif score >= 80:
|
||||
pct = 0.20
|
||||
else:
|
||||
pct = 0.15
|
||||
return round(self.cash * pct, 2)
|
||||
|
||||
def get_portfolio_value(self, current_prices: dict[str, float]) -> dict:
|
||||
holdings_value = sum(
|
||||
pos["quantity"] * current_prices.get(sym, pos["entry_price"])
|
||||
for sym, pos in self.positions.items()
|
||||
)
|
||||
total_value = self.cash + holdings_value
|
||||
total_pnl = total_value - self.initial_capital
|
||||
pnl_pct = (total_pnl / self.initial_capital) * 100
|
||||
|
||||
winning = sum(1 for t in self.trades if t["side"] == "SELL" and self._trade_pnl(t) > 0)
|
||||
total_sells = sum(1 for t in self.trades if t["side"] == "SELL")
|
||||
win_rate = (winning / total_sells * 100) if total_sells > 0 else 0
|
||||
|
||||
return {
|
||||
"total_value": round(total_value, 2),
|
||||
"cash": round(self.cash, 2),
|
||||
"holdings_value": round(holdings_value, 2),
|
||||
"total_pnl": round(total_pnl, 2),
|
||||
"pnl_pct": round(pnl_pct, 2),
|
||||
"win_rate": round(win_rate, 1),
|
||||
"open_positions": len(self.positions),
|
||||
}
|
||||
|
||||
def _trade_pnl(self, sell_trade: dict) -> float:
|
||||
matching_buys = [
|
||||
t for t in self.trades
|
||||
if t["coin"] == sell_trade["coin"] and t["side"] == "BUY"
|
||||
and t["timestamp"] <= sell_trade["timestamp"]
|
||||
]
|
||||
if matching_buys:
|
||||
latest_buy = matching_buys[-1]
|
||||
return (sell_trade["price"] - latest_buy["price"]) * sell_trade["quantity"]
|
||||
return 0
|
||||
45
engine/signal.py
Normal file
45
engine/signal.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from config import DEFAULT_WEIGHTS
|
||||
|
||||
class SignalEngine:
|
||||
def __init__(self):
|
||||
self.weights = dict(DEFAULT_WEIGHTS)
|
||||
|
||||
def set_weights(self, weights: dict):
|
||||
total = sum(weights.values())
|
||||
if abs(total - 1.0) > 0.01:
|
||||
raise ValueError(f"Weights must sum to 1.0, got {total}")
|
||||
self.weights = weights
|
||||
|
||||
def compute_score(self, technical: float, news: float, social: float, ai: float) -> float:
|
||||
score = (
|
||||
technical * self.weights["technical"]
|
||||
+ news * self.weights["news"]
|
||||
+ social * self.weights["social"]
|
||||
+ ai * self.weights["ai"]
|
||||
)
|
||||
return round(score, 1)
|
||||
|
||||
def classify(self, score: float) -> str:
|
||||
if score >= 70:
|
||||
return "BUY"
|
||||
elif score >= 40:
|
||||
return "HOLD"
|
||||
return "SELL"
|
||||
|
||||
def rank_coins(self, coins: dict[str, dict]) -> list[dict]:
|
||||
results = []
|
||||
for symbol, scores in coins.items():
|
||||
composite = self.compute_score(
|
||||
scores["technical"], scores["news"], scores["social"], scores["ai"]
|
||||
)
|
||||
results.append({
|
||||
"symbol": symbol,
|
||||
"technical": scores["technical"],
|
||||
"news": scores["news"],
|
||||
"social": scores["social"],
|
||||
"ai": scores["ai"],
|
||||
"composite": composite,
|
||||
"signal": self.classify(composite),
|
||||
})
|
||||
results.sort(key=lambda x: x["composite"], reverse=True)
|
||||
return results
|
||||
20
engine/surge.py
Normal file
20
engine/surge.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SurgeDetector:
|
||||
def __init__(self, multiplier: float = 3.0):
|
||||
self.multiplier = multiplier
|
||||
|
||||
def detect(self, tickers: list[dict], avg_volumes: dict[str, float]) -> list[str]:
|
||||
surged = []
|
||||
for t in tickers:
|
||||
symbol = t["symbol"]
|
||||
if not symbol.endswith("USDT"):
|
||||
continue
|
||||
current_vol = float(t.get("quoteVolume", 0))
|
||||
avg_vol = avg_volumes.get(symbol, 0)
|
||||
if avg_vol > 0 and current_vol >= avg_vol * self.multiplier:
|
||||
logger.info(f"Surge detected: {symbol} volume {current_vol:.0f} vs avg {avg_vol:.0f}")
|
||||
surged.append(symbol)
|
||||
return surged
|
||||
Reference in New Issue
Block a user