Files
maxbot/bot.py
T

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()