saadrizvi09 commited on
Commit
a4a7fb9
Β·
1 Parent(s): f647b21

Add Market feature with manual trading and P&L tracking, rename Live Trading to Trading Bot

Browse files
Files changed (4) hide show
  1. live_trading.py +27 -11
  2. main.py +199 -1
  3. manual_trading.py +593 -0
  4. models.py +3 -1
live_trading.py CHANGED
@@ -160,24 +160,40 @@ def get_portfolio_value():
160
 
161
 
162
  def get_recent_trades_from_db(user_email: str, limit: int = 20) -> List[dict]:
163
- """Get recent trades from database"""
164
  try:
165
  with Session(engine) as session:
 
166
  statement = select(Trade).where(
167
- Trade.user_email == user_email
 
168
  ).order_by(Trade.executed_at.desc()).limit(limit)
169
  trades = session.exec(statement).all()
170
 
171
- return [{
172
- 'symbol': t.symbol,
173
- 'side': t.side,
174
- 'price': t.price,
175
- 'quantity': t.quantity,
176
- 'total': t.total,
177
- 'pnl': t.pnl,
178
- 'time': t.executed_at.isoformat()
179
- } for t in trades]
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  except Exception as e:
 
181
  return []
182
 
183
 
 
160
 
161
 
162
  def get_recent_trades_from_db(user_email: str, limit: int = 20) -> List[dict]:
163
+ """Get recent trades from database - only bot trades (excludes manual trades)"""
164
  try:
165
  with Session(engine) as session:
166
+ # Filter out manual trades by excluding session_ids starting with "manual_"
167
  statement = select(Trade).where(
168
+ Trade.user_email == user_email,
169
+ ~Trade.session_id.startswith("manual_")
170
  ).order_by(Trade.executed_at.desc()).limit(limit)
171
  trades = session.exec(statement).all()
172
 
173
+ result = []
174
+ for t in trades:
175
+ trade_dict = {
176
+ 'symbol': t.symbol,
177
+ 'side': t.side,
178
+ 'price': t.price,
179
+ 'quantity': t.quantity,
180
+ 'total': t.total,
181
+ 'pnl': t.pnl,
182
+ 'time': t.executed_at.isoformat()
183
+ }
184
+
185
+ # Calculate pnl_percent for SELL trades
186
+ if t.side == "SELL" and t.pnl is not None and t.total > 0:
187
+ # PnL percent = (pnl / cost_basis) * 100
188
+ cost_basis = t.total - t.pnl
189
+ if cost_basis > 0:
190
+ trade_dict['pnl_percent'] = (t.pnl / cost_basis) * 100
191
+
192
+ result.append(trade_dict)
193
+
194
+ return result
195
  except Exception as e:
196
+ print(f"Error getting bot trades: {e}")
197
  return []
198
 
199
 
main.py CHANGED
@@ -428,4 +428,202 @@ def get_simulated_session(session_id: str, current_user: str = Depends(get_curre
428
  status = get_simulated_session_status(session_id)
429
  if "error" in status:
430
  raise HTTPException(status_code=404, detail=status["error"])
431
- return status
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  status = get_simulated_session_status(session_id)
429
  if "error" in status:
430
  raise HTTPException(status_code=404, detail=status["error"])
431
+ return status
432
+
433
+
434
+ # --- MANUAL TRADING ROUTES (Market Page) ---
435
+
436
+ class ManualBuyRequest(BaseModel):
437
+ symbol: str # e.g., 'BTC', 'ETH'
438
+ usdt_amount: float # Amount in USDT to spend
439
+
440
+ class ManualSellRequest(BaseModel):
441
+ symbol: str # e.g., 'BTC', 'ETH'
442
+ quantity: float # Amount of asset to sell
443
+
444
+ class ManualSellPercentRequest(BaseModel):
445
+ symbol: str # e.g., 'BTC', 'ETH'
446
+ percentage: float # Percentage of holdings to sell (0-100)
447
+
448
+
449
+ @app.post("/api/market/buy")
450
+ def manual_buy(req: ManualBuyRequest, current_user: str = Depends(get_current_user)):
451
+ """
452
+ Execute a manual buy order from the Market page.
453
+ This is independent from automated trading bot strategies.
454
+ Updates portfolio and creates trade log entry.
455
+ """
456
+ from manual_trading import execute_manual_buy
457
+ from database import initialize_portfolio_if_empty
458
+
459
+ # Ensure user has portfolio initialized
460
+ initialize_portfolio_if_empty(user_email=current_user)
461
+
462
+ # Validate input
463
+ if req.usdt_amount <= 0:
464
+ raise HTTPException(status_code=400, detail="Amount must be positive")
465
+
466
+ if req.usdt_amount < 1:
467
+ raise HTTPException(status_code=400, detail="Minimum buy amount is 1 USDT")
468
+
469
+ success, trade_info, error = execute_manual_buy(
470
+ symbol=req.symbol,
471
+ usdt_amount=req.usdt_amount,
472
+ user_email=current_user
473
+ )
474
+
475
+ if not success:
476
+ raise HTTPException(status_code=400, detail=error)
477
+
478
+ return {
479
+ "success": True,
480
+ "message": f"Successfully bought {trade_info['quantity']:.8f} {req.symbol}",
481
+ "trade": trade_info
482
+ }
483
+
484
+
485
+ @app.post("/api/market/sell")
486
+ def manual_sell(req: ManualSellRequest, current_user: str = Depends(get_current_user)):
487
+ """
488
+ Execute a manual sell order from the Market page.
489
+ This is independent from automated trading bot strategies.
490
+ Updates portfolio and creates trade log entry.
491
+ """
492
+ from manual_trading import execute_manual_sell
493
+ from database import initialize_portfolio_if_empty
494
+
495
+ # Ensure user has portfolio initialized
496
+ initialize_portfolio_if_empty(user_email=current_user)
497
+
498
+ # Validate input
499
+ if req.quantity <= 0:
500
+ raise HTTPException(status_code=400, detail="Quantity must be positive")
501
+
502
+ success, trade_info, error = execute_manual_sell(
503
+ symbol=req.symbol,
504
+ quantity=req.quantity,
505
+ user_email=current_user
506
+ )
507
+
508
+ if not success:
509
+ raise HTTPException(status_code=400, detail=error)
510
+
511
+ return {
512
+ "success": True,
513
+ "message": f"Successfully sold {trade_info['quantity']:.8f} {req.symbol}",
514
+ "trade": trade_info
515
+ }
516
+
517
+
518
+ @app.post("/api/market/sell-percent")
519
+ def manual_sell_percent(req: ManualSellPercentRequest, current_user: str = Depends(get_current_user)):
520
+ """
521
+ Sell a percentage of holdings for a specific asset.
522
+ Useful for quick "Sell 25%", "Sell 50%", "Sell All" actions.
523
+ """
524
+ from manual_trading import execute_manual_sell, get_user_balance
525
+ from database import initialize_portfolio_if_empty
526
+
527
+ # Ensure user has portfolio initialized
528
+ initialize_portfolio_if_empty(user_email=current_user)
529
+
530
+ # Validate percentage
531
+ if req.percentage <= 0 or req.percentage > 100:
532
+ raise HTTPException(status_code=400, detail="Percentage must be between 0 and 100")
533
+
534
+ # Get current balance
535
+ balance = get_user_balance(req.symbol.upper(), current_user)
536
+ if balance <= 0:
537
+ raise HTTPException(status_code=400, detail=f"No {req.symbol} holdings to sell")
538
+
539
+ # Calculate quantity to sell
540
+ quantity_to_sell = balance * (req.percentage / 100)
541
+
542
+ success, trade_info, error = execute_manual_sell(
543
+ symbol=req.symbol,
544
+ quantity=quantity_to_sell,
545
+ user_email=current_user
546
+ )
547
+
548
+ if not success:
549
+ raise HTTPException(status_code=400, detail=error)
550
+
551
+ return {
552
+ "success": True,
553
+ "message": f"Successfully sold {req.percentage}% ({trade_info['quantity']:.8f}) {req.symbol}",
554
+ "trade": trade_info
555
+ }
556
+
557
+
558
+ @app.get("/api/market/trades")
559
+ def get_manual_trades(limit: int = 50, current_user: str = Depends(get_current_user)):
560
+ """Get manual trade history for the current user"""
561
+ from manual_trading import get_manual_trade_history
562
+
563
+ trades = get_manual_trade_history(current_user, limit)
564
+ return {"trades": trades}
565
+
566
+
567
+ @app.get("/api/market/prices")
568
+ def get_market_prices(current_user: str = Depends(get_current_user)):
569
+ """
570
+ Get current prices for all supported assets.
571
+ Useful for initial page load before WebSocket connects.
572
+ """
573
+ from manual_trading import get_prices_for_assets
574
+
575
+ prices = get_prices_for_assets()
576
+ return {"prices": prices}
577
+
578
+
579
+ @app.get("/api/market/assets")
580
+ def get_supported_assets(current_user: str = Depends(get_current_user)):
581
+ """Get list of supported assets for manual trading"""
582
+ from manual_trading import SUPPORTED_ASSETS
583
+
584
+ assets = [
585
+ {"symbol": "BTC", "name": "Bitcoin", "logo": "β‚Ώ", "color": "#F7931A"},
586
+ {"symbol": "ETH", "name": "Ethereum", "logo": "Ξ", "color": "#627EEA"},
587
+ {"symbol": "SOL", "name": "Solana", "logo": "β—Ž", "color": "#14F195"},
588
+ {"symbol": "LINK", "name": "Chainlink", "logo": "⬑", "color": "#2A5ADA"},
589
+ {"symbol": "DOGE", "name": "Dogecoin", "logo": "Ð", "color": "#C2A633"},
590
+ {"symbol": "BNB", "name": "BNB", "logo": "⬑", "color": "#F3BA2F"},
591
+ ]
592
+
593
+ return {"assets": [a for a in assets if a["symbol"] in SUPPORTED_ASSETS]}
594
+
595
+
596
+ @app.get("/api/market/cost-basis/{symbol}")
597
+ def get_cost_basis(symbol: str, current_user: str = Depends(get_current_user)):
598
+ """
599
+ Get the average cost basis and investment info for a specific asset.
600
+ Used to show estimated PnL before selling.
601
+ """
602
+ from manual_trading import get_asset_cost_basis, get_current_price_from_binance, TRADING_FEE
603
+
604
+ cost_info = get_asset_cost_basis(symbol.upper(), current_user)
605
+
606
+ # Get current price to calculate unrealized PnL
607
+ current_price = get_current_price_from_binance(symbol.upper(), "USDT")
608
+
609
+ if current_price and cost_info['balance'] > 0:
610
+ current_value = current_price * cost_info['balance']
611
+ fee_estimate = current_value * TRADING_FEE
612
+ net_value = current_value - fee_estimate
613
+ unrealized_pnl = net_value - cost_info['total_invested']
614
+ unrealized_pnl_percent = ((net_value / cost_info['total_invested']) - 1) * 100 if cost_info['total_invested'] > 0 else 0.0
615
+ else:
616
+ current_value = 0.0
617
+ unrealized_pnl = 0.0
618
+ unrealized_pnl_percent = 0.0
619
+
620
+ return {
621
+ "symbol": symbol.upper(),
622
+ "balance": cost_info['balance'],
623
+ "avg_cost_basis": cost_info['avg_cost_basis'],
624
+ "total_invested": cost_info['total_invested'],
625
+ "current_price": current_price,
626
+ "current_value": current_value,
627
+ "unrealized_pnl": unrealized_pnl,
628
+ "unrealized_pnl_percent": unrealized_pnl_percent
629
+ }
manual_trading.py ADDED
@@ -0,0 +1,593 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Manual Trading Service
3
+ Handles manual buy/sell operations for the Market page
4
+ Operates independently from automated trading bot strategies
5
+ """
6
+ from typing import Optional, Tuple, List
7
+ from datetime import datetime
8
+ from sqlmodel import Session, select
9
+ from database import engine
10
+ from models import PortfolioAsset, Trade
11
+ import uuid
12
+
13
+ # Trading fee (0.1% as typical exchange fee)
14
+ TRADING_FEE = 0.001
15
+
16
+ # Supported trading pairs for manual trading
17
+ SUPPORTED_ASSETS = ["BTC", "ETH", "SOL", "LINK", "DOGE", "BNB"]
18
+
19
+
20
+ def get_current_price_from_binance(symbol: str, quote: str = "USDT") -> Optional[float]:
21
+ """
22
+ Fetch current market price from Binance API
23
+
24
+ Args:
25
+ symbol: Base asset (e.g., 'BTC', 'ETH')
26
+ quote: Quote asset (e.g., 'USDT')
27
+
28
+ Returns:
29
+ Current price or None if error
30
+ """
31
+ try:
32
+ from binance.client import Client
33
+ import os
34
+ from dotenv import load_dotenv
35
+
36
+ load_dotenv()
37
+
38
+ api_key = os.getenv("BINANCE_API_KEY", "")
39
+ api_secret = os.getenv("BINANCE_SECRET_KEY", "")
40
+
41
+ client = Client(api_key, api_secret, testnet=True)
42
+ trading_pair = f"{symbol}{quote}"
43
+ ticker = client.get_symbol_ticker(symbol=trading_pair)
44
+ return float(ticker['price'])
45
+ except Exception as e:
46
+ print(f"[ManualTrading] Binance fetch failed for {symbol}/{quote}: {e}")
47
+
48
+ # Fallback to Yahoo Finance
49
+ try:
50
+ import yfinance as yf
51
+ ticker_symbol = f"{symbol}-USD"
52
+ ticker = yf.Ticker(ticker_symbol)
53
+ price_data = ticker.history(period="1d", interval="1m")
54
+
55
+ if not price_data.empty:
56
+ return float(price_data['Close'].iloc[-1])
57
+ return None
58
+ except Exception as yf_error:
59
+ print(f"[ManualTrading] Yahoo Finance fallback failed: {yf_error}")
60
+ return None
61
+
62
+
63
+ def get_user_balance(symbol: str, user_email: str) -> float:
64
+ """
65
+ Get current balance of an asset from user's portfolio
66
+
67
+ Args:
68
+ symbol: Asset symbol (e.g., 'USDT', 'BTC')
69
+ user_email: User identifier
70
+
71
+ Returns:
72
+ Current balance (0.0 if asset not found)
73
+ """
74
+ with Session(engine) as session:
75
+ statement = select(PortfolioAsset).where(
76
+ PortfolioAsset.symbol == symbol,
77
+ PortfolioAsset.user_email == user_email
78
+ )
79
+ asset = session.exec(statement).first()
80
+ return asset.balance if asset else 0.0
81
+
82
+
83
+ def get_asset_cost_basis(symbol: str, user_email: str) -> dict:
84
+ """
85
+ Get the average cost basis and investment info for an asset
86
+
87
+ Args:
88
+ symbol: Asset symbol (e.g., 'BTC', 'ETH')
89
+ user_email: User identifier
90
+
91
+ Returns:
92
+ Dict with avg_cost_basis, total_invested, and balance
93
+ """
94
+ with Session(engine) as session:
95
+ statement = select(PortfolioAsset).where(
96
+ PortfolioAsset.symbol == symbol,
97
+ PortfolioAsset.user_email == user_email
98
+ )
99
+ asset = session.exec(statement).first()
100
+
101
+ if asset:
102
+ return {
103
+ 'symbol': symbol,
104
+ 'balance': asset.balance,
105
+ 'avg_cost_basis': getattr(asset, 'avg_cost_basis', 0.0) or 0.0,
106
+ 'total_invested': getattr(asset, 'total_invested', 0.0) or 0.0
107
+ }
108
+ return {
109
+ 'symbol': symbol,
110
+ 'balance': 0.0,
111
+ 'avg_cost_basis': 0.0,
112
+ 'total_invested': 0.0
113
+ }
114
+
115
+
116
+ def record_trade(
117
+ session: Session,
118
+ user_email: str,
119
+ symbol: str,
120
+ side: str,
121
+ price: float,
122
+ quantity: float,
123
+ total: float,
124
+ fee: float = 0.0,
125
+ pnl: Optional[float] = None
126
+ ) -> Trade:
127
+ """
128
+ Record a trade in the database
129
+
130
+ Args:
131
+ session: Database session
132
+ user_email: User identifier
133
+ symbol: Trading pair (e.g., 'BTCUSDT')
134
+ side: 'BUY' or 'SELL'
135
+ price: Execution price
136
+ quantity: Amount traded
137
+ total: Total value in quote currency
138
+ fee: Trading fee
139
+ pnl: Profit/Loss (for SELL trades)
140
+
141
+ Returns:
142
+ Created Trade object
143
+ """
144
+ trade = Trade(
145
+ session_id=f"manual_{uuid.uuid4().hex[:8]}",
146
+ user_email=user_email,
147
+ symbol=symbol,
148
+ side=side,
149
+ price=price,
150
+ quantity=quantity,
151
+ total=total,
152
+ pnl=pnl,
153
+ order_id=f"MANUAL_{uuid.uuid4().hex[:12].upper()}",
154
+ executed_at=datetime.now()
155
+ )
156
+ session.add(trade)
157
+ return trade
158
+
159
+
160
+ def execute_manual_buy(
161
+ symbol: str,
162
+ usdt_amount: float,
163
+ user_email: str,
164
+ current_price: Optional[float] = None
165
+ ) -> Tuple[bool, Optional[dict], Optional[str]]:
166
+ """
167
+ Execute a manual BUY order
168
+
169
+ Args:
170
+ symbol: Asset to buy (e.g., 'BTC', 'ETH')
171
+ usdt_amount: Amount in USDT to spend
172
+ user_email: User identifier
173
+ current_price: Optional price (fetched if not provided)
174
+
175
+ Returns:
176
+ Tuple of (success: bool, trade_info: dict or None, error_message: str or None)
177
+ """
178
+ # Validate symbol
179
+ if symbol.upper() not in SUPPORTED_ASSETS:
180
+ return False, None, f"Asset {symbol} is not supported for manual trading"
181
+
182
+ symbol = symbol.upper()
183
+
184
+ # Get current market price if not provided
185
+ price = current_price or get_current_price_from_binance(symbol, "USDT")
186
+ if price is None:
187
+ return False, None, f"Could not fetch price for {symbol}/USDT"
188
+
189
+ # Calculate quantity to buy and fees
190
+ fee = usdt_amount * TRADING_FEE
191
+ usdt_after_fee = usdt_amount - fee
192
+ quantity_to_buy = usdt_after_fee / price
193
+
194
+ # Check if user has enough USDT
195
+ usdt_balance = get_user_balance("USDT", user_email)
196
+
197
+ if usdt_balance < usdt_amount:
198
+ return False, None, f"Insufficient USDT balance. Required: {usdt_amount:.2f}, Available: {usdt_balance:.2f}"
199
+
200
+ # Execute trade in database transaction
201
+ try:
202
+ with Session(engine) as session:
203
+ # Deduct USDT
204
+ usdt_stmt = select(PortfolioAsset).where(
205
+ PortfolioAsset.symbol == "USDT",
206
+ PortfolioAsset.user_email == user_email
207
+ )
208
+ usdt_asset = session.exec(usdt_stmt).first()
209
+ usdt_asset.balance -= usdt_amount
210
+ session.add(usdt_asset)
211
+
212
+ # Add purchased asset and update cost basis
213
+ asset_stmt = select(PortfolioAsset).where(
214
+ PortfolioAsset.symbol == symbol,
215
+ PortfolioAsset.user_email == user_email
216
+ )
217
+ asset = session.exec(asset_stmt).first()
218
+
219
+ if asset:
220
+ # Calculate new weighted average cost basis
221
+ old_balance = asset.balance
222
+ old_total_invested = getattr(asset, 'total_invested', 0.0) or 0.0
223
+ new_total_invested = old_total_invested + usdt_amount
224
+ new_balance = old_balance + quantity_to_buy
225
+
226
+ # Weighted average: (old_invested + new_invested) / new_total_quantity
227
+ asset.avg_cost_basis = new_total_invested / new_balance if new_balance > 0 else 0.0
228
+ asset.total_invested = new_total_invested
229
+ asset.balance = new_balance
230
+ session.add(asset)
231
+ else:
232
+ new_asset = PortfolioAsset(
233
+ symbol=symbol,
234
+ balance=quantity_to_buy,
235
+ user_email=user_email,
236
+ avg_cost_basis=usdt_amount / quantity_to_buy if quantity_to_buy > 0 else 0.0,
237
+ total_invested=usdt_amount
238
+ )
239
+ session.add(new_asset)
240
+ asset = new_asset
241
+
242
+ # Record the trade
243
+ trade = record_trade(
244
+ session=session,
245
+ user_email=user_email,
246
+ symbol=f"{symbol}USDT",
247
+ side="BUY",
248
+ price=price,
249
+ quantity=quantity_to_buy,
250
+ total=usdt_amount,
251
+ fee=fee
252
+ )
253
+
254
+ session.commit()
255
+
256
+ trade_info = {
257
+ 'order_id': trade.order_id,
258
+ 'symbol': f"{symbol}USDT",
259
+ 'side': 'BUY',
260
+ 'price': price,
261
+ 'quantity': quantity_to_buy,
262
+ 'usdt_spent': usdt_amount,
263
+ 'fee': fee,
264
+ 'net_quantity': quantity_to_buy,
265
+ 'executed_at': trade.executed_at.isoformat(),
266
+ 'new_balance': {
267
+ 'USDT': usdt_asset.balance,
268
+ symbol: asset.balance if asset else quantity_to_buy
269
+ }
270
+ }
271
+
272
+ print(f"[ManualTrading] βœ… BUY executed: {quantity_to_buy:.8f} {symbol} @ ${price:.2f}")
273
+ print(f" Spent: ${usdt_amount:.2f} USDT (Fee: ${fee:.4f})")
274
+
275
+ return True, trade_info, None
276
+
277
+ except Exception as e:
278
+ print(f"[ManualTrading] ❌ BUY transaction failed: {e}")
279
+ return False, None, f"Transaction failed: {str(e)}"
280
+
281
+
282
+ def execute_manual_sell(
283
+ symbol: str,
284
+ quantity: float,
285
+ user_email: str,
286
+ current_price: Optional[float] = None
287
+ ) -> Tuple[bool, Optional[dict], Optional[str]]:
288
+ """
289
+ Execute a manual SELL order
290
+
291
+ Args:
292
+ symbol: Asset to sell (e.g., 'BTC', 'ETH')
293
+ quantity: Amount of asset to sell
294
+ user_email: User identifier
295
+ current_price: Optional price (fetched if not provided)
296
+
297
+ Returns:
298
+ Tuple of (success: bool, trade_info: dict or None, error_message: str or None)
299
+ """
300
+ # Validate symbol
301
+ if symbol.upper() not in SUPPORTED_ASSETS:
302
+ return False, None, f"Asset {symbol} is not supported for manual trading"
303
+
304
+ symbol = symbol.upper()
305
+
306
+ # Get current market price if not provided
307
+ price = current_price or get_current_price_from_binance(symbol, "USDT")
308
+ if price is None:
309
+ return False, None, f"Could not fetch price for {symbol}/USDT"
310
+
311
+ # Check if user has enough of the asset to sell
312
+ asset_balance = get_user_balance(symbol, user_email)
313
+
314
+ if asset_balance < quantity:
315
+ return False, None, f"Insufficient {symbol} balance. Required: {quantity:.8f}, Available: {asset_balance:.8f}"
316
+
317
+ # Calculate proceeds after fee
318
+ gross_proceeds = price * quantity
319
+ fee = gross_proceeds * TRADING_FEE
320
+ net_proceeds = gross_proceeds - fee
321
+
322
+ # Execute trade in database transaction
323
+ try:
324
+ with Session(engine) as session:
325
+ # Deduct sold asset and calculate PnL
326
+ asset_stmt = select(PortfolioAsset).where(
327
+ PortfolioAsset.symbol == symbol,
328
+ PortfolioAsset.user_email == user_email
329
+ )
330
+ asset = session.exec(asset_stmt).first()
331
+
332
+ # Calculate PnL based on cost basis
333
+ avg_cost_basis = getattr(asset, 'avg_cost_basis', 0.0) or 0.0
334
+ total_invested = getattr(asset, 'total_invested', 0.0) or 0.0
335
+
336
+ # Cost of the sold portion
337
+ cost_of_sold = avg_cost_basis * quantity
338
+ # PnL = What we received - What we paid (including fees)
339
+ pnl = net_proceeds - cost_of_sold
340
+ pnl_percent = ((net_proceeds / cost_of_sold) - 1) * 100 if cost_of_sold > 0 else 0.0
341
+
342
+ # Update asset balance and total invested
343
+ old_balance = asset.balance
344
+ new_balance = old_balance - quantity
345
+
346
+ # Proportionally reduce total invested
347
+ if old_balance > 0:
348
+ proportion_sold = quantity / old_balance
349
+ invested_sold = total_invested * proportion_sold
350
+ asset.total_invested = total_invested - invested_sold
351
+ else:
352
+ asset.total_invested = 0.0
353
+
354
+ asset.balance = new_balance
355
+ # Keep avg_cost_basis the same (it's still relevant for remaining holdings)
356
+ session.add(asset)
357
+
358
+ # Add USDT proceeds
359
+ usdt_stmt = select(PortfolioAsset).where(
360
+ PortfolioAsset.symbol == "USDT",
361
+ PortfolioAsset.user_email == user_email
362
+ )
363
+ usdt_asset = session.exec(usdt_stmt).first()
364
+
365
+ if usdt_asset:
366
+ usdt_asset.balance += net_proceeds
367
+ session.add(usdt_asset)
368
+ else:
369
+ new_usdt = PortfolioAsset(
370
+ symbol="USDT",
371
+ balance=net_proceeds,
372
+ user_email=user_email
373
+ )
374
+ session.add(new_usdt)
375
+
376
+ # Record the trade with PnL
377
+ trade = record_trade(
378
+ session=session,
379
+ user_email=user_email,
380
+ symbol=f"{symbol}USDT",
381
+ side="SELL",
382
+ price=price,
383
+ quantity=quantity,
384
+ total=net_proceeds,
385
+ fee=fee,
386
+ pnl=pnl
387
+ )
388
+
389
+ session.commit()
390
+
391
+ trade_info = {
392
+ 'order_id': trade.order_id,
393
+ 'symbol': f"{symbol}USDT",
394
+ 'side': 'SELL',
395
+ 'price': price,
396
+ 'quantity': quantity,
397
+ 'gross_proceeds': gross_proceeds,
398
+ 'fee': fee,
399
+ 'net_proceeds': net_proceeds,
400
+ 'avg_cost_basis': avg_cost_basis,
401
+ 'cost_of_sold': cost_of_sold,
402
+ 'pnl': pnl,
403
+ 'pnl_percent': pnl_percent,
404
+ 'executed_at': trade.executed_at.isoformat(),
405
+ 'new_balance': {
406
+ 'USDT': usdt_asset.balance if usdt_asset else net_proceeds,
407
+ symbol: asset.balance
408
+ }
409
+ }
410
+
411
+ pnl_emoji = "πŸ“ˆ" if pnl >= 0 else "πŸ“‰"
412
+ print(f"[ManualTrading] βœ… SELL executed: {quantity:.8f} {symbol} @ ${price:.2f}")
413
+ print(f" Received: ${net_proceeds:.2f} USDT (Fee: ${fee:.4f})")
414
+ print(f" {pnl_emoji} PnL: ${pnl:.2f} ({pnl_percent:+.2f}%)")
415
+
416
+ return True, trade_info, None
417
+
418
+ except Exception as e:
419
+ print(f"[ManualTrading] ❌ SELL transaction failed: {e}")
420
+ return False, None, f"Transaction failed: {str(e)}"
421
+
422
+
423
+ def get_manual_trade_history(user_email: str, limit: int = 50) -> List[dict]:
424
+ """
425
+ Get manual trade history for a user
426
+
427
+ Args:
428
+ user_email: User identifier
429
+ limit: Maximum number of trades to return
430
+
431
+ Returns:
432
+ List of trade dictionaries
433
+ """
434
+ with Session(engine) as session:
435
+ statement = select(Trade).where(
436
+ Trade.user_email == user_email,
437
+ Trade.session_id.startswith("manual_")
438
+ ).order_by(Trade.executed_at.desc()).limit(limit)
439
+
440
+ trades = session.exec(statement).all()
441
+
442
+ result = []
443
+ for trade in trades:
444
+ # Calculate pnl_percent for sell trades
445
+ pnl_percent = None
446
+ if trade.side == "SELL" and trade.pnl is not None:
447
+ # cost_basis = total - pnl (what we got minus profit = what we paid)
448
+ cost_basis = trade.total - trade.pnl
449
+ if cost_basis > 0:
450
+ pnl_percent = (trade.pnl / cost_basis) * 100
451
+
452
+ result.append({
453
+ 'id': trade.id,
454
+ 'order_id': trade.order_id,
455
+ 'symbol': trade.symbol,
456
+ 'side': trade.side,
457
+ 'price': trade.price,
458
+ 'quantity': trade.quantity,
459
+ 'total': trade.total,
460
+ 'pnl': trade.pnl,
461
+ 'pnl_percent': pnl_percent,
462
+ 'time': trade.executed_at.isoformat() if trade.executed_at else None
463
+ })
464
+
465
+ return result
466
+
467
+
468
+ def get_prices_for_assets(assets: List[str] = None) -> dict:
469
+ """
470
+ Get current prices for multiple assets
471
+
472
+ Args:
473
+ assets: List of asset symbols (defaults to SUPPORTED_ASSETS)
474
+
475
+ Returns:
476
+ Dictionary mapping symbol to price data
477
+ """
478
+ if assets is None:
479
+ assets = SUPPORTED_ASSETS
480
+
481
+ prices = {}
482
+ for asset in assets:
483
+ price = get_current_price_from_binance(asset, "USDT")
484
+ if price:
485
+ prices[asset] = {
486
+ 'symbol': asset,
487
+ 'price': price,
488
+ 'pair': f"{asset}USDT"
489
+ }
490
+
491
+ return prices
492
+
493
+
494
+ # ============================================
495
+ # PLACEHOLDER: Real Binance Order Execution
496
+ # ============================================
497
+
498
+ def execute_binance_order(
499
+ symbol: str,
500
+ side: str, # 'BUY' or 'SELL'
501
+ order_type: str, # 'MARKET', 'LIMIT'
502
+ quantity: float,
503
+ price: Optional[float] = None, # Required for LIMIT orders
504
+ api_key: Optional[str] = None,
505
+ api_secret: Optional[str] = None
506
+ ) -> Tuple[bool, Optional[dict], Optional[str]]:
507
+ """
508
+ PLACEHOLDER: Execute a real order on Binance exchange
509
+
510
+ This function is a placeholder for when you want to execute
511
+ real orders on Binance. Uncomment and configure the code below.
512
+
513
+ Args:
514
+ symbol: Trading pair (e.g., 'BTCUSDT')
515
+ side: 'BUY' or 'SELL'
516
+ order_type: 'MARKET' or 'LIMIT'
517
+ quantity: Amount to trade
518
+ price: Limit price (required for LIMIT orders)
519
+ api_key: Binance API key
520
+ api_secret: Binance API secret
521
+
522
+ Returns:
523
+ Tuple of (success: bool, order_info: dict or None, error_message: str or None)
524
+ """
525
+
526
+ # ===== UNCOMMENT THIS CODE TO ENABLE REAL TRADING =====
527
+ #
528
+ # from binance.client import Client
529
+ # from binance.exceptions import BinanceAPIException
530
+ # import os
531
+ # from dotenv import load_dotenv
532
+ #
533
+ # load_dotenv()
534
+ #
535
+ # # Use provided keys or fall back to environment variables
536
+ # key = api_key or os.getenv("BINANCE_API_KEY")
537
+ # secret = api_secret or os.getenv("BINANCE_SECRET_KEY")
538
+ #
539
+ # if not key or not secret:
540
+ # return False, None, "Binance API credentials not configured"
541
+ #
542
+ # try:
543
+ # client = Client(key, secret)
544
+ #
545
+ # if order_type == 'MARKET':
546
+ # if side == 'BUY':
547
+ # order = client.create_order(
548
+ # symbol=symbol,
549
+ # side=Client.SIDE_BUY,
550
+ # type=Client.ORDER_TYPE_MARKET,
551
+ # quantity=quantity
552
+ # )
553
+ # else:
554
+ # order = client.create_order(
555
+ # symbol=symbol,
556
+ # side=Client.SIDE_SELL,
557
+ # type=Client.ORDER_TYPE_MARKET,
558
+ # quantity=quantity
559
+ # )
560
+ # elif order_type == 'LIMIT':
561
+ # if price is None:
562
+ # return False, None, "Price required for LIMIT orders"
563
+ #
564
+ # if side == 'BUY':
565
+ # order = client.create_order(
566
+ # symbol=symbol,
567
+ # side=Client.SIDE_BUY,
568
+ # type=Client.ORDER_TYPE_LIMIT,
569
+ # timeInForce=Client.TIME_IN_FORCE_GTC,
570
+ # quantity=quantity,
571
+ # price=str(price)
572
+ # )
573
+ # else:
574
+ # order = client.create_order(
575
+ # symbol=symbol,
576
+ # side=Client.SIDE_SELL,
577
+ # type=Client.ORDER_TYPE_LIMIT,
578
+ # timeInForce=Client.TIME_IN_FORCE_GTC,
579
+ # quantity=quantity,
580
+ # price=str(price)
581
+ # )
582
+ #
583
+ # return True, order, None
584
+ #
585
+ # except BinanceAPIException as e:
586
+ # return False, None, f"Binance API Error: {e.message}"
587
+ # except Exception as e:
588
+ # return False, None, f"Order execution failed: {str(e)}"
589
+ #
590
+ # ===== END OF REAL TRADING CODE =====
591
+
592
+ # For now, return a message indicating this is a placeholder
593
+ return False, None, "Real Binance trading not enabled. Using simulated trading."
models.py CHANGED
@@ -44,4 +44,6 @@ class PortfolioAsset(SQLModel, table=True):
44
  """Internal simulated wallet for paper trading"""
45
  symbol: str = Field(primary_key=True) # e.g., 'USDT', 'BTC', 'ETH'
46
  user_email: str = Field(primary_key=True, index=True) # Support multi-user portfolios
47
- balance: float = Field(default=0.0)
 
 
 
44
  """Internal simulated wallet for paper trading"""
45
  symbol: str = Field(primary_key=True) # e.g., 'USDT', 'BTC', 'ETH'
46
  user_email: str = Field(primary_key=True, index=True) # Support multi-user portfolios
47
+ balance: float = Field(default=0.0)
48
+ avg_cost_basis: float = Field(default=0.0) # Average buy price per unit
49
+ total_invested: float = Field(default=0.0) # Total USDT invested in this asset