505 lines
19 KiB
Python
505 lines
19 KiB
Python
"""
|
|
bot.py — Main entry point for the TSLA grid trading bot.
|
|
=========================================================
|
|
Run this file to start the bot.
|
|
|
|
USAGE:
|
|
------
|
|
python3 bot.py --mode dryrun # default — safe, no actions
|
|
python3 bot.py --mode confirm # ask before every action
|
|
python3 bot.py --mode live # fully autonomous
|
|
|
|
FIRST TIME SETUP:
|
|
-----------------
|
|
1. Get API key from Capital.com → Settings → API integrations
|
|
2. Set environment variables:
|
|
export CAPITAL_API_KEY="your_key"
|
|
export CAPITAL_IDENTIFIER="your@email.com"
|
|
export CAPITAL_PASSWORD="your_password"
|
|
3. Run dryrun first and verify the output looks correct.
|
|
4. Run confirm mode for several days, approve each action manually.
|
|
5. Only switch to live mode when fully confident.
|
|
|
|
TO RUN AS A SYSTEM SERVICE (auto-start on boot):
|
|
-------------------------------------------------
|
|
See README.md for systemd setup instructions.
|
|
|
|
PROJECT STRUCTURE:
|
|
------------------
|
|
bot.py ← YOU ARE HERE — main loop, startup, CLI
|
|
config.py ← ALL settings — edit this for day-to-day changes
|
|
client.py ← Capital.com API calls
|
|
state.py ← Account state snapshot, field access helpers
|
|
calculator.py ← Grid maths, survival check, gap detection
|
|
grid.py ← Order management logic (gap-filling, TP rules)
|
|
actions.py ← dry/confirm/live action handler
|
|
bot.log ← All activity logged here (auto-created)
|
|
|
|
ENHANCING THE BOT:
|
|
------------------
|
|
The code is intentionally split so future additions are isolated:
|
|
- Telegram alerts → new file telegram.py, called from run_loop()
|
|
- Profit tracking → new file tracker.py, log closed positions
|
|
- Claude AI → new file ai_advisor.py, called on edge cases
|
|
- Web dashboard → new file dashboard.py, reads state and logs
|
|
- Auto threshold → add equity check in run_loop(), update config
|
|
Each addition touches one file without breaking anything else.
|
|
|
|
WHAT THE BOT DOES EACH LOOP (every 60 seconds):
|
|
------------------------------------------------
|
|
1. Refresh API session if tokens approaching expiry
|
|
2. Fetch: positions, orders, account balance, TSLA price, GBP/USD rate
|
|
3. Check for abnormal price gap (> 20%) — pause if detected
|
|
4. Check margin health — stop placing orders if level < 100%
|
|
5. Manage bottom two positions (TP rules)
|
|
6. Manage order grid (cancel out-of-window, fill gaps)
|
|
7. Sleep 60 seconds, repeat
|
|
"""
|
|
|
|
import argparse
|
|
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
from datetime import datetime
|
|
|
|
import config
|
|
from client import CapitalClient
|
|
from state import BotState, pos_level, pos_size, pos_tp, ord_level, ord_size
|
|
from calculator import GridCalculator
|
|
from grid import GridManager
|
|
from actions import ActionHandler
|
|
from calibration import Calibrator, setup_calibration_log
|
|
|
|
log = logging.getLogger("maxbot")
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# LOGGING SETUP
|
|
# ─────────────────────────────────────────────
|
|
|
|
def setup_logging():
|
|
"""
|
|
Two log files in the logs/ subfolder:
|
|
logs/bot.log — full detail, every loop, for debugging
|
|
logs/actions.log — only actions taken (orders placed/cancelled, TP changes)
|
|
logs/calibration.log — startup calibration reports, appended each run
|
|
|
|
Monitoring tip:
|
|
tail -f ~/maxbot/logs/actions.log ← clean, just what the bot did
|
|
tail -f ~/maxbot/logs/bot.log ← full detail if something goes wrong
|
|
cat ~/maxbot/logs/calibration.log ← progress reports over time
|
|
"""
|
|
base = os.path.dirname(os.path.abspath(__file__))
|
|
logs_dir = os.path.join(base, "logs")
|
|
os.makedirs(logs_dir, exist_ok=True)
|
|
fmt = "%(asctime)s [%(levelname)s] %(message)s"
|
|
|
|
full_handler = logging.FileHandler(os.path.join(logs_dir, "bot.log"))
|
|
full_handler.setFormatter(logging.Formatter(fmt))
|
|
|
|
console_handler = logging.StreamHandler(sys.stdout)
|
|
console_handler.setFormatter(logging.Formatter(fmt))
|
|
|
|
root = logging.getLogger()
|
|
root.setLevel(logging.INFO)
|
|
root.addHandler(full_handler)
|
|
root.addHandler(console_handler)
|
|
|
|
actions_handler = logging.FileHandler(os.path.join(logs_dir, "actions.log"))
|
|
actions_handler.setFormatter(logging.Formatter(fmt))
|
|
actions_handler.setLevel(logging.WARNING)
|
|
logging.getLogger("maxbot.actions").addHandler(actions_handler)
|
|
|
|
setup_calibration_log()
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# MAIN BOT CLASS
|
|
# ─────────────────────────────────────────────
|
|
|
|
class TeslaGridBot:
|
|
|
|
def __init__(self, mode: str):
|
|
self.mode = mode
|
|
self.client = CapitalClient()
|
|
self.state = BotState()
|
|
self.calculator = GridCalculator()
|
|
self.actions = ActionHandler(mode, self.client)
|
|
self.grid = GridManager(self.state, self.calculator, self.actions)
|
|
self.calibrator = Calibrator(self.state, self.calculator)
|
|
self.loop_count = 0
|
|
self.gap_pause = False
|
|
self._prev_position_count = None
|
|
self._prev_order_count = None
|
|
self._prev_equity = None
|
|
|
|
# ─────────────────────────────────────────
|
|
# STARTUP
|
|
# ─────────────────────────────────────────
|
|
|
|
def banner(self):
|
|
labels = {
|
|
"dryrun": "DRY RUN — no actions will be taken",
|
|
"confirm": "CONFIRM MODE — will ask before every action",
|
|
"live": "LIVE MODE — fully autonomous",
|
|
}
|
|
print("\n" + "=" * 60)
|
|
print(" TSLA Grid Trading Bot — Capital.com")
|
|
print(f" Mode: {labels.get(self.mode, self.mode)}")
|
|
print(f" Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
|
print("=" * 60 + "\n")
|
|
|
|
def fetch_state(self):
|
|
"""Refresh all state from Capital.com API."""
|
|
self.client.refresh_session_if_needed()
|
|
positions = self.client.get_positions()
|
|
orders = self.client.get_working_orders()
|
|
account = self.client.get_account_details()
|
|
price_data = self.client.get_price(config.EPIC)
|
|
gbpusd = self.client.get_gbpusd()
|
|
self.state.update(positions, orders, account, price_data, gbpusd)
|
|
|
|
def print_state(self):
|
|
"""Print current account and grid status to log."""
|
|
s = self.state
|
|
log.info("─" * 50)
|
|
log.info(f"TSLA price: ${s.current_price:.2f}")
|
|
log.info(f"Equity: £{s.equity:.2f}")
|
|
log.info(f"Available: £{s.available:.2f}")
|
|
log.info(f"Margin in use: £{s.total_margin_gbp:.2f}")
|
|
log.info(f"Margin level: {s.margin_level_pct:.1f}%")
|
|
log.info(f"Open positions: {len(s.positions)}")
|
|
log.info(f"Pending orders: {len(s.orders)}")
|
|
log.info(f"Highest open: ${s.highest_open_price:.2f}")
|
|
log.info(f"Lowest level: ${s.lowest_level_price:.2f}")
|
|
log.info(f"GBP/USD: {s.gbpusd:.4f}")
|
|
calc = self.calculator
|
|
# Use survival-gated lookups — same as manage_orders uses
|
|
spacing = calc.get_spacing_pct(
|
|
s.equity, s.highest_open_price, s.all_levels(), s.gbpusd
|
|
)
|
|
depth = calc.get_queue_depth(
|
|
s.equity, spacing, s.highest_open_price, s.all_levels(), s.gbpusd
|
|
)
|
|
full_sim = calc._simulate_grid(
|
|
s.highest_open_price, spacing, s.gbpusd, s.all_levels()
|
|
)
|
|
survival = calc.survival_check(
|
|
s.equity, s.highest_open_price, full_sim, s.gbpusd,
|
|
override_pct=60.0
|
|
)
|
|
# Find max safe % for clear display
|
|
max_safe = 0.0
|
|
for pct in range(10, 81, 5):
|
|
sim = calc._simulate_grid(
|
|
s.highest_open_price, spacing, s.gbpusd, s.all_levels()
|
|
)
|
|
r = calc.survival_check(
|
|
s.equity, s.highest_open_price, sim, s.gbpusd,
|
|
override_pct=float(pct)
|
|
)
|
|
if r["safe"]:
|
|
max_safe = float(pct)
|
|
else:
|
|
break
|
|
|
|
log.info(f"Grid spacing: {spacing}%")
|
|
log.info(f"Queue depth: {depth}")
|
|
log.info(
|
|
f"Survival: max safe {max_safe:.0f}% | "
|
|
f"60% sim {'✓' if survival['safe'] else '✗'} "
|
|
f"(margin at floor: {survival['margin_level_at_floor_pct']:.1f}%)"
|
|
)
|
|
log.info("─" * 50)
|
|
|
|
def startup_audit(self):
|
|
"""
|
|
Full account audit on first start.
|
|
Reads and displays everything before taking any action.
|
|
"""
|
|
log.info("=" * 50)
|
|
log.info("STARTUP AUDIT")
|
|
log.info("=" * 50)
|
|
|
|
self.fetch_state()
|
|
self.print_state()
|
|
|
|
s = self.state
|
|
|
|
# Print all open positions
|
|
sorted_pos = s.positions_sorted_asc()
|
|
log.info(f"\nOpen positions ({len(sorted_pos)}) — lowest to highest:")
|
|
for p in sorted_pos:
|
|
tp = pos_tp(p)
|
|
tp_str = f"${tp:.2f}" if tp else "NONE (manual close)"
|
|
upl = float(p["position"].get("upl", 0))
|
|
log.info(
|
|
f" ${pos_level(p):.2f} | "
|
|
f"size {p['position']['size']} | "
|
|
f"TP {tp_str} | "
|
|
f"P&L £{upl / self.state.gbpusd:.2f}"
|
|
)
|
|
|
|
# Print all pending orders
|
|
sorted_ord = s.orders_sorted_asc()
|
|
log.info(f"\nPending orders ({len(sorted_ord)}) — lowest to highest:")
|
|
for o in sorted_ord:
|
|
log.info(f" ${ord_level(o):.2f} | size {ord_size(o)}")
|
|
|
|
# Full survival check
|
|
log.info("")
|
|
self._log_survival_check()
|
|
|
|
log.info("\nStartup audit complete.")
|
|
log.info("=" * 50)
|
|
|
|
# ─────────────────────────────────────────
|
|
# CHECKS
|
|
# ─────────────────────────────────────────
|
|
|
|
def _log_survival_check(self):
|
|
"""Run and log full survival check — always simulates full 60% grid."""
|
|
s = self.state
|
|
calc = self.calculator
|
|
# Always simulate full grid at 60% drop — honest worst case
|
|
full_sim = calc._simulate_grid(
|
|
s.highest_open_price, calc.get_spacing_pct(s.equity),
|
|
s.gbpusd, s.all_levels()
|
|
)
|
|
result = calc.survival_check(
|
|
s.equity, s.highest_open_price,
|
|
full_sim, s.gbpusd,
|
|
override_pct=60.0
|
|
)
|
|
status = "✓ SAFE" if result["safe"] else "✗ UNSAFE"
|
|
log.info(f"Survival check (full 60% grid simulation): {status}")
|
|
log.info(f" Floor price: ${result['floor_price']:.2f}")
|
|
log.info(f" Total margin (GBP): £{result['total_margin_gbp']:.2f}")
|
|
log.info(f" Equity at floor: £{result['equity_at_floor_gbp']:.2f}")
|
|
log.info(f" Margin level there: {result['margin_level_at_floor_pct']:.1f}%")
|
|
log.info(f" Unrealised P&L: £{result['unrealised_loss_gbp']:.2f}")
|
|
if not result["safe"]:
|
|
log.warning(
|
|
"SURVIVAL CHECK FAILED — "
|
|
"account may be margin called at 60% drop."
|
|
)
|
|
return result
|
|
|
|
def check_price_gap(self) -> bool:
|
|
"""
|
|
Detect abnormal price gaps between loops.
|
|
A gap > GAP_ALERT_PCT triggers a pause on order placement.
|
|
This protects against Monday gap-downs, stock splits, news events.
|
|
The bot will resume normal operation next loop after alerting.
|
|
To resume immediately, restart the bot.
|
|
"""
|
|
s = self.state
|
|
if s.last_price and s.last_price > 0:
|
|
change_pct = abs(s.current_price - s.last_price) / s.last_price * 100
|
|
if change_pct >= config.GAP_ALERT_PCT:
|
|
log.warning("!" * 50)
|
|
log.warning(
|
|
f"PRICE GAP DETECTED: "
|
|
f"${s.last_price:.2f} → ${s.current_price:.2f} "
|
|
f"({change_pct:.1f}%)"
|
|
)
|
|
log.warning(
|
|
"This may be a gap-down open, stock split, or major news event."
|
|
)
|
|
log.warning(
|
|
"Bot is PAUSING order placement this cycle. "
|
|
"Review Capital.com manually."
|
|
)
|
|
log.warning("!" * 50)
|
|
self.gap_pause = True
|
|
return True
|
|
s.last_price = s.current_price
|
|
self.gap_pause = False
|
|
return False
|
|
|
|
def check_margin_health(self) -> str:
|
|
"""
|
|
Check current margin level against Capital.com thresholds.
|
|
Returns: 'ok', 'warning1', 'warning2', or 'closeout'
|
|
|
|
Capital.com UK retail margin call stages:
|
|
> 100% → Normal
|
|
= 100% → Warning 1: cannot open new positions
|
|
= 75% → Warning 2: urgent
|
|
= 50% → Auto closeout begins
|
|
"""
|
|
ml = self.state.margin_level_pct
|
|
if ml <= 50:
|
|
log.warning(
|
|
f"MARGIN CLOSEOUT LEVEL ({ml:.1f}% ≤ 50%). "
|
|
"Capital.com may be closing positions!"
|
|
)
|
|
return "closeout"
|
|
if ml <= 75:
|
|
log.warning(
|
|
f"MARGIN WARNING 2 ({ml:.1f}% ≤ 75%). "
|
|
"Cannot place new orders."
|
|
)
|
|
return "warning2"
|
|
if ml <= 100:
|
|
log.warning(
|
|
f"MARGIN WARNING 1 ({ml:.1f}% ≤ 100%). "
|
|
"Cannot place new orders."
|
|
)
|
|
return "warning1"
|
|
return "ok"
|
|
|
|
# ─────────────────────────────────────────
|
|
# MAIN LOOP
|
|
# ─────────────────────────────────────────
|
|
|
|
def run_loop(self):
|
|
"""Single iteration of the main loop."""
|
|
self.loop_count += 1
|
|
|
|
self.fetch_state()
|
|
s = self.state
|
|
|
|
# Detect meaningful changes since last loop
|
|
pos_count = len(s.positions)
|
|
ord_count = len(s.orders)
|
|
equity_delta = abs((s.equity - self._prev_equity) if self._prev_equity else 0)
|
|
state_changed = (
|
|
pos_count != self._prev_position_count or
|
|
ord_count != self._prev_order_count or
|
|
equity_delta > 0.50 # equity moved more than 50p
|
|
)
|
|
|
|
if state_changed:
|
|
# Full state print when something meaningful changed
|
|
log.info(f"\n{'=' * 50}")
|
|
log.info(f"Loop #{self.loop_count} — {datetime.now().strftime('%H:%M:%S')} — STATE CHANGED")
|
|
self.print_state()
|
|
else:
|
|
# Quiet heartbeat — one line only
|
|
log.info(
|
|
f"Loop #{self.loop_count} — {datetime.now().strftime('%H:%M:%S')} — "
|
|
f"no changes "
|
|
f"(price ${s.current_price:.2f} "
|
|
f"equity £{s.equity:.2f} "
|
|
f"pos {pos_count} orders {ord_count})"
|
|
)
|
|
|
|
# Update previous state
|
|
self._prev_position_count = pos_count
|
|
self._prev_order_count = ord_count
|
|
self._prev_equity = s.equity
|
|
|
|
# Gap detection
|
|
if self.check_price_gap():
|
|
log.warning("Gap detected — skipping order management this cycle.")
|
|
return
|
|
|
|
# Margin health
|
|
margin_status = self.check_margin_health()
|
|
if margin_status != "ok":
|
|
log.warning(
|
|
f"Margin [{margin_status}] — managing positions only, "
|
|
"no new orders."
|
|
)
|
|
self.grid.manage_bottom_two_positions()
|
|
return
|
|
|
|
# Normal operation
|
|
self.grid.manage_bottom_two_positions()
|
|
self.grid.manage_orders()
|
|
|
|
def run(self):
|
|
"""Start the bot."""
|
|
self.banner()
|
|
|
|
log.info("Connecting to Capital.com...")
|
|
# Fail fast if credentials are still placeholders
|
|
if config.API_KEY == "YOUR_API_KEY_HERE" or \
|
|
config.IDENTIFIER == "your@email.com" or \
|
|
config.PASSWORD == "YOUR_PASSWORD_HERE":
|
|
log.error("Credentials not set — update environment variables:")
|
|
log.error(" export CAPITAL_API_KEY=your_key")
|
|
log.error(" export CAPITAL_IDENTIFIER=your@email.com")
|
|
log.error(" export CAPITAL_PASSWORD=your_password")
|
|
sys.exit(1)
|
|
try:
|
|
self.client.create_session()
|
|
except Exception as e:
|
|
log.error(f"Connection failed: {e}")
|
|
log.error(
|
|
"Check credentials: "
|
|
"CAPITAL_API_KEY, CAPITAL_IDENTIFIER, CAPITAL_PASSWORD"
|
|
)
|
|
sys.exit(1)
|
|
|
|
self.startup_audit()
|
|
try:
|
|
self.calibrator.run()
|
|
except Exception as e:
|
|
log.error(f"Calibration failed: {e}", exc_info=True)
|
|
|
|
if self.mode == "dryrun":
|
|
log.info("\nDRY RUN complete — no actions taken.")
|
|
log.info("Review output above, then run with --mode confirm")
|
|
return
|
|
|
|
log.info(
|
|
f"\nEntering main loop "
|
|
f"(every {config.LOOP_INTERVAL_SECS}s)..."
|
|
)
|
|
log.info("Press Ctrl+C to stop.\n")
|
|
|
|
try:
|
|
while True:
|
|
try:
|
|
self.run_loop()
|
|
except KeyboardInterrupt:
|
|
raise
|
|
except Exception as e:
|
|
log.error(f"Loop error: {e}", exc_info=True)
|
|
log.info("Waiting 30s before retry...")
|
|
time.sleep(30)
|
|
continue
|
|
time.sleep(config.LOOP_INTERVAL_SECS)
|
|
|
|
except KeyboardInterrupt:
|
|
log.info(f"\nBot stopped by user.")
|
|
log.info(self.actions.summary())
|
|
|
|
|
|
# ─────────────────────────────────────────────
|
|
# CLI
|
|
# ─────────────────────────────────────────────
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="TSLA Grid Trading Bot — Capital.com",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Modes:
|
|
dryrun Connect and read everything. Print what bot would do. No actions.
|
|
confirm Ask y/n before every action. Run this for days before going live.
|
|
live Fully autonomous. Only use after thorough testing.
|
|
|
|
Examples:
|
|
python3 bot.py # dryrun (default)
|
|
python3 bot.py --mode confirm # confirm mode
|
|
python3 bot.py --mode live # live mode
|
|
"""
|
|
)
|
|
parser.add_argument(
|
|
"--mode",
|
|
choices=["dryrun", "confirm", "live"],
|
|
default="dryrun",
|
|
help="Operation mode (default: dryrun)"
|
|
)
|
|
args = parser.parse_args()
|
|
setup_logging()
|
|
TeslaGridBot(mode=args.mode).run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|