160 lines
6.1 KiB
Python
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
|