Files
maxbot/state.py
T

160 lines
6.1 KiB
Python

"""
state.py — Account state and position/order field helpers.
===========================================================
Holds the current snapshot of the account, positions, and orders.
Updated every loop from the API. All other modules read from here.
FIELD ACCESS HELPERS:
---------------------
Capital.com uses nested dicts with non-obvious field names.
Rather than repeating o["workingOrderData"]["orderLevel"] everywhere,
use the helper functions at the bottom of this file:
pos_level(p) → float entry price of a position
pos_size(p) → float size in shares
pos_id(p) → deal ID string
pos_tp(p) → take profit price (float) or None if not set
pos_upl(p) → unrealised P&L in USD (float)
ord_level(o) → float entry price of a working order
ord_size(o) → float size in shares
ord_id(o) → deal ID string
"""
import logging
import config
log = logging.getLogger("maxbot.state")
# ─────────────────────────────────────────────
# FIELD ACCESS HELPERS
# Use these everywhere instead of raw dict access.
# If Capital.com ever changes field names, fix it here only.
# ─────────────────────────────────────────────
def pos_level(p) -> float:
return float(p["position"]["level"])
def pos_size(p) -> float:
return float(p["position"]["size"])
def pos_id(p) -> str:
return p["position"]["dealId"]
def pos_tp(p):
"""Returns take profit price as float, or None if not set."""
tp = p["position"].get("profitLevel")
return float(tp) if tp is not None else None
def pos_upl(p) -> float:
"""Unrealised P&L in USD."""
return float(p["position"].get("upl", 0))
def ord_level(o) -> float:
return float(o["workingOrderData"]["orderLevel"])
def ord_size(o) -> float:
return float(o["workingOrderData"]["orderSize"])
def ord_id(o) -> str:
return o["workingOrderData"]["dealId"]
# ─────────────────────────────────────────────
# BOT STATE
# ─────────────────────────────────────────────
class BotState:
"""
Snapshot of the account updated every loop.
All values are recalculated from scratch each time fetch() is called.
"""
def __init__(self):
self.positions: list = []
self.orders: list = []
self.equity: float = 0.0 # running equity in GBP
self.funds: float = 0.0 # deposited funds in GBP
self.available: float = 0.0 # available to trade in GBP
self.current_price: float = 0.0 # TSLA mid price in USD
self.gbpusd: float = config.GBPUSD_FALLBACK
self.highest_open_price: float = 0.0 # highest open position entry
self.lowest_level_price: float = 0.0 # lowest of positions OR orders
self.total_margin_gbp: float = 0.0 # estimated margin in use
self.margin_level_pct: float = 999.0
self.last_price: float = None # previous loop price (gap detection)
def update(self, positions, orders, account, price_data, gbpusd):
"""Recalculate all state from fresh API data."""
self.positions = positions
self.orders = orders
self.gbpusd = gbpusd
# Account balance
for acc in account.get("accounts", []):
if acc.get("preferred"):
bal = acc.get("balance", {})
self.equity = float(bal.get("balance", 0))
self.funds = float(bal.get("deposit", 0))
self.available = float(bal.get("available", 0))
break
# Current TSLA mid price
snap = price_data.get("snapshot", {})
self.current_price = (
float(snap.get("bid", 0)) +
float(snap.get("offer", 0))
) / 2.0
# Highest open position entry price
# When no positions are open, keep the last known value rather than
# falling back to current price — this prevents the grid floor from
# collapsing to current_price * 0.40 after all positions close.
open_prices = [pos_level(p) for p in positions]
if open_prices:
self.highest_open_price = max(open_prices)
elif self.highest_open_price == 0.0:
# First run with no positions — use current price as seed
self.highest_open_price = self.current_price
# else: keep existing highest_open_price from previous loop
# Lowest level across all positions and orders
order_prices = [ord_level(o) for o in orders]
all_prices = open_prices + order_prices
self.lowest_level_price = (
min(all_prices) if all_prices else self.current_price
)
# Approximate total margin in use (GBP)
# Formula: entry_price * size * margin_rate / gbpusd
self.total_margin_gbp = sum(
pos_level(p) * pos_size(p) * config.MARGIN_RATE / gbpusd
for p in positions
)
# Margin level % = equity / margin * 100
self.margin_level_pct = (
(self.equity / self.total_margin_gbp * 100)
if self.total_margin_gbp > 0 else 999.0
)
def positions_sorted_asc(self) -> list:
"""Open positions sorted by entry price, lowest first."""
return sorted(self.positions, key=pos_level)
def orders_sorted_asc(self) -> list:
"""Pending orders sorted by level, lowest first."""
return sorted(self.orders, key=ord_level)
def all_levels(self) -> list:
"""
Combined list of all open positions and pending orders
as dicts with 'price' and 'size' keys.
Used by the survival check calculator.
"""
levels = []
for p in self.positions:
levels.append({"price": pos_level(p), "size": pos_size(p)})
for o in self.orders:
levels.append({"price": ord_level(o), "size": ord_size(o)})
return levels