Initial commit — TSLA grid trading bot
- Grid strategy with survival-gated spacing and depth - Full 60% drop simulation for all survival checks - Calibration report with auto-updating survival threshold - Transaction history sync from Capital.com - Dip mode with bottom-two TP rules
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
"""
|
||||
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
|
||||
spacing = calc.get_spacing_pct(s.equity)
|
||||
depth = calc.get_queue_depth(s.equity)
|
||||
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...")
|
||||
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()
|
||||
Reference in New Issue
Block a user