"""Limit checks for the Grok Bot Desk Kit. Run this before any send. Every limit is read from limits.md. The order cannot set max loss, live trading, human approval, or any other limit. python3 validate_limits.py limits.md proposal.json --realized-today 0 --open-positions 0 If ok is false, do not send. limits.json sits beside this file. It lists asset types and status words. It is not the operator's limits. """ from __future__ import annotations import argparse import json import sys from decimal import Decimal, InvalidOperation from pathlib import Path RULES_PATH = Path(__file__).with_name("limits.json") _RULES = None _FIELD_NAMES = { "LIVE TRADING": "live_trading", "HUMAN APPROVAL": "human_approval", "MAX LOSS PER TRADE": "max_loss_per_trade", "MAX LOSS": "max_loss_per_trade", "MAX LOSS PER DAY": "max_loss_per_day", "MAX OPEN POSITIONS": "max_open_positions", "MAX LEVERAGE": "max_leverage", "NEVER TRADE": "never_trade", "PAPER FLAG": "paper_flag", "KILL SWITCH": "kill_switch", } def load_rules(path=None): global _RULES raw = json.loads(Path(path or RULES_PATH).read_text()) _RULES = raw return raw def rules(): return _RULES if _RULES is not None else load_rules() def _num(value): if value is None or isinstance(value, bool): return None if isinstance(value, str): text = value.strip() if not text or _unverified(text): return None value = text.replace(",", "").replace("$", "") try: return Decimal(str(value)) except (InvalidOperation, ValueError): return None def _unverified(value): if value is None: return True text = str(value).strip() if not text: return True tokens = {item.upper() for item in rules()["unverified_tokens"]} return text.upper() in tokens def _canon(key): return " ".join(str(key).strip().upper().replace("_", " ").split()) def _never_list(value): if value is None: return [] if isinstance(value, (list, tuple)): return [str(item).strip() for item in value if str(item).strip()] text = str(value).strip() if not text: return [] if text.upper() in {"BLANK", "NONE", "N/A", "NA"}: return [] parts = [] for item in text.replace(";", ",").split(","): name = item.strip() if name and name.upper() not in {"BLANK", "NONE", "N/A", "NA"}: parts.append(name) return parts def parse_limits_md(text): """Read the limits.md shape the install writes. Specs below SPECS are ignored.""" if text.startswith("\ufeff"): text = text[1:] found = {} for raw_line in text.splitlines(): line = raw_line.strip() if line.startswith("- "): line = line[2:].strip() if not line or line.startswith("#") or line.startswith("```"): continue if _canon(line).startswith("SPECS"): break if ":" not in line: continue key, value = line.split(":", 1) field = _FIELD_NAMES.get(_canon(key)) if not field: continue if field == "max_loss_per_trade" and _canon(key) == "MAX LOSS" and "max_loss_per_trade" in found: continue found[field] = value.strip() if "never_trade" in found: found["never_trade"] = _never_list(found["never_trade"]) return found def load_operator_limits(source): """A dict, a path to limits.md, or the markdown text itself.""" if isinstance(source, dict): return _limits_from_mapping(source) if isinstance(source, Path): return parse_limits_md(source.read_text(encoding="utf-8")) if isinstance(source, str): if "\n" not in source and Path(source).is_file(): return parse_limits_md(Path(source).read_text(encoding="utf-8")) return parse_limits_md(source) raise TypeError("limits must be a dict, a limits.md path, or the markdown text") def _limits_from_mapping(raw): found = {} for key, value in raw.items(): if _canon(key) in {name.replace("_", " ").upper() for name in ( "live_trading", "human_approval", "max_loss_per_trade", "max_loss_per_day", "max_open_positions", "max_leverage", "never_trade", "paper_flag", "kill_switch", )}: found[_canon(key).lower().replace(" ", "_")] = value for key, value in raw.items(): field = _FIELD_NAMES.get(_canon(key)) if field and field not in found: found[field] = value if "never_trade" in found: found["never_trade"] = _never_list(found["never_trade"]) return found def dollar_risk(price, invalidation, multiplier, qty): """Dollar risk of a futures or FX line: |price - invalidation| × multiplier × quantity.""" price_n = _num(price) inv_n = _num(invalidation) mult = _num(multiplier) qty_n = _num(qty) if None in (price_n, inv_n, mult, qty_n): return None if mult <= 0 or qty_n <= 0: return None return abs(price_n - inv_n) * mult * qty_n def tick_risk(price, invalidation, tick_size, tick_value, qty): """Dollar risk from ticks: (|price - invalidation| / tick size) × tick value × quantity.""" price_n = _num(price) inv_n = _num(invalidation) size = _num(tick_size) value = _num(tick_value) qty_n = _num(qty) if None in (price_n, inv_n, size, value, qty_n): return None if size <= 0 or value <= 0 or qty_n <= 0: return None return (abs(price_n - inv_n) / size) * value * qty_n def trade_action(intent, position_qty, order_qty): """Buy or sell from the current position. Long quantity is positive. Short quantity is negative. A close larger than the position is not ok, because the extra size opens the other side. A sell with no long position is not a close. """ pos = _num(position_qty) qty = _num(order_qty) if pos is None: pos = Decimal(0) if qty is None or qty <= 0: return None, "quantity must be positive" intent = (intent or "").strip().lower().replace("-", "_").replace(" ", "_") if intent == "flatten": if pos > 0: intent = "close_long" if qty != pos: return None, "flatten quantity must match the position" elif pos < 0: intent = "close_short" if qty != abs(pos): return None, "flatten quantity must match the position" else: return None, "flat position has nothing to flatten" if intent == "close_long": if pos <= 0: return None, "close long requires a long position" if qty > pos: return None, "close larger than the position opens the other side" return "sell", None if intent == "close_short": if pos >= 0: return None, "close short requires a short position" if qty > abs(pos): return None, "close larger than the position opens the other side" return "buy", None if intent == "open_long": if pos < 0: return None, "open long against a short is not a close" return "buy", None if intent == "open_short": if pos > 0: return None, "open short against a long is not a close" return "sell", None return None, "intent missing" def may_resend(prior): """False until an existing order is recovered. Unknown and timeout never resend.""" if not prior: return True status = str(prior.get("status") or "unknown").strip().lower() table = rules() if status in set(table["open_statuses"]) or status in ("", "unknown", "timeout"): return False if status in set(table["terminal_no_resend"]): return False if status in set(table["resend_only_after_recovery"]): return bool(prior.get("recovered")) return False def _dedupe(items): out = [] seen = set() for item in items: if item not in seen: seen.add(item) out.append(item) return out def _known_assets(): return {str(item).strip().lower() for item in rules()["assets"]} def _loss_cap(value): number = _num(value) if number is None or number <= 0: return None return number def _position_cap(value): if _unverified(value): return None number = _num(value) if number is None or number < 0: return None return number def _position_count(value): if isinstance(value, (list, tuple)): return Decimal(len(value)) if _unverified(value): return None number = _num(value) if number is None or number < 0: return None return number def _intent_key(proposal): raw = proposal.get("intent") if raw is None or _unverified(raw): return "" return str(raw).strip().lower().replace("-", "_").replace(" ", "_") def _adds_risk(proposal): intent = _intent_key(proposal) if intent in ("close_long", "close_short", "flatten"): return False return True def _opens_new_position(proposal): if _intent_key(proposal) not in ("open_long", "open_short"): if not _intent_key(proposal): return True return False pos = _num(proposal.get("position_qty")) if pos is None: pos = Decimal(0) return pos == 0 def _flag(limits, name, default): raw = limits.get(name) if raw is None or str(raw).strip() == "": return default return str(raw).strip().lower() def validate_limits(limits, proposal, book=None): """Return ok, reasons, dollar_risk, and the position-derived action. `limits` is limits.md (a dict, a path, or the markdown). Max loss, live trading, human approval, and the other limits come only from that file. `proposal` is one line. `book` holds today's realized P&L and the open position count. Those inputs are not read from the order. Sending with LIVE_TRADING off is not ok. A draft (sending false) may still fail on an unverified max loss. """ limits = load_operator_limits(limits) if not isinstance(proposal, dict): raise TypeError("proposal must be a dict") if book is None: book = {} if not isinstance(book, dict): raise TypeError("book must be a dict") reasons = [] table = rules() symbol = str(proposal.get("symbol") or "").strip() if "never_trade" not in limits: reasons.append("never-trade list missing") banned = set() else: banned = {str(item).strip().upper() for item in limits.get("never_trade") or [] if str(item).strip()} if symbol and symbol.upper() in banned: reasons.append("symbol is on the never-trade list") live = _flag(limits, "live_trading", table["live_trading_default"]) if live not in ("on", "off"): reasons.append("LIVE_TRADING must be on or off") live = "off" sending = bool(proposal.get("sending")) if sending and live != "on": reasons.append("LIVE_TRADING is off") approval = _flag(limits, "human_approval", "on") if sending and approval == "on" and not proposal.get("approved"): reasons.append("human approval missing") max_loss = _loss_cap(limits.get("max_loss_per_trade")) if max_loss is None: reasons.append("max loss unverified") asset_raw = proposal.get("asset") asset = "" if asset_raw is None else str(asset_raw).strip().lower() known = asset in _known_assets() if not known: reasons.append("asset type not listed") dollar = None if known and asset in set(table["spec_assets"]): for field in table["spec_fields"]: if field == "multiplier": continue if _unverified(proposal.get(field)): reasons.append(field.replace("_", " ") + " unverified") mult = _num(proposal.get("multiplier")) if mult is None or mult <= 0: reasons.append("multiplier unverified") dollar = dollar_risk(proposal.get("price"), proposal.get("invalidation"), proposal.get("multiplier"), proposal.get("qty")) if dollar is None: reasons.append("dollar risk inputs missing") else: alt = tick_risk(proposal.get("price"), proposal.get("invalidation"), proposal.get("tick_size"), proposal.get("tick_value"), proposal.get("qty")) if alt is None: reasons.append("tick size or tick value unverified") elif abs(alt - dollar) > Decimal("0.01"): reasons.append("tick value does not match multiplier") if max_loss is not None and dollar > max_loss: reasons.append("dollar risk exceeds max loss") if known and asset in set(table["crypto_assets"]): session = "" if _unverified(proposal.get("session")) else str(proposal.get("session")).strip() if "24/7" not in session: reasons.append("crypto session is 24/7 unless a halt is named on that line") if known and asset == "crypto_perp": missing_perp = [] for field in table["perp_fields"]: if _unverified(proposal.get(field)): missing_perp.append(field.replace("_", " ") + " unverified") reasons.extend(missing_perp) lev = _num(proposal.get("leverage")) if "leverage unverified" not in missing_perp and (lev is None or lev <= 0): reasons.append("leverage unverified") cap = _loss_cap(limits.get("max_leverage")) if cap is None: reasons.append("max leverage unverified") elif lev is not None and lev > cap: reasons.append("leverage over cap") liq = _num(proposal.get("liquidation_price")) price = _num(proposal.get("price")) if "liquidation price unverified" not in missing_perp and liq is None: reasons.append("liquidation price unverified") elif liq is not None and price is not None: intent = str(proposal.get("intent") or "").lower() pos = _num(proposal.get("position_qty")) or Decimal(0) long_side = intent in ("open_long", "close_long") or (intent == "flatten" and pos > 0) short_side = intent in ("open_short", "close_short") or (intent == "flatten" and pos < 0) if long_side and liq >= price: reasons.append("liquidation price on the wrong side") if short_side and liq <= price: reasons.append("liquidation price on the wrong side") day_cap = _loss_cap(limits.get("max_loss_per_day")) if day_cap is None: reasons.append("max loss per day unverified") realized = _num(book.get("realized_pnl_today")) if "realized_pnl_today" not in book or realized is None: reasons.append("realized P&L today unverified") realized = None open_cap = _position_cap(limits.get("max_open_positions")) if open_cap is None: reasons.append("max open positions unverified") open_count = _position_count(book.get("open_positions")) if "open_positions" in book else None if open_count is None: reasons.append("open positions unverified") if day_cap is not None and realized is not None and _adds_risk(proposal): loss_so_far = -realized if realized < 0 else Decimal(0) if loss_so_far >= day_cap: reasons.append("daily loss limit already hit") else: added = dollar if added is None and asset not in set(table["spec_assets"]): added = max_loss if added is not None and loss_so_far + added > day_cap: reasons.append("dollar risk exceeds daily loss limit") if open_cap is not None and open_count is not None and _opens_new_position(proposal) and open_count >= open_cap: reasons.append("open positions at the max") action = None intent_raw = proposal.get("intent") if intent_raw is None or _unverified(intent_raw): reasons.append("intent missing") else: action, err = trade_action(intent_raw, proposal.get("position_qty"), proposal.get("qty")) if err: reasons.append(err) side = proposal.get("side") if action and side and str(side).strip().lower() != action: reasons.append("side does not match position") if "prior" in proposal and proposal.get("prior") is not None: if not may_resend(proposal.get("prior")): reasons.append("recover state before resend") reasons = _dedupe(reasons) return { "ok": len(reasons) == 0, "reasons": reasons, "dollar_risk": None if dollar is None else format(dollar, "f"), "action": action, } def main(argv=None): parser = argparse.ArgumentParser(description="Check one line against limits.md. If ok is false, do not send.") parser.add_argument("limits", help="Path to limits.md") parser.add_argument("proposal", help="Path to the proposal JSON file, or - to read it from stdin") parser.add_argument("--realized-today", required=True, help="Today's realized P&L. A loss is negative.") parser.add_argument("--open-positions", required=True, help="How many positions are open now") args = parser.parse_args(argv) limits_path = Path(args.limits) if not limits_path.is_file(): print("limits file was not found", file=sys.stderr) return 2 if args.proposal == "-": raw = sys.stdin.read() else: proposal_path = Path(args.proposal) if not proposal_path.is_file(): print("proposal file was not found", file=sys.stderr) return 2 raw = proposal_path.read_text(encoding="utf-8") try: proposal = json.loads(raw) except json.JSONDecodeError: print("proposal file is not valid JSON", file=sys.stderr) return 2 if not isinstance(proposal, dict): print("proposal file is not valid JSON", file=sys.stderr) return 2 result = validate_limits(limits_path, proposal, { "realized_pnl_today": args.realized_today, "open_positions": args.open_positions, }) print(json.dumps(result, indent=2)) return 0 if result["ok"] else 1 if __name__ == "__main__": raise SystemExit(main())