1668 lines
74 KiB
Python
1668 lines
74 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .config import (
|
|
read_fixed_universe,
|
|
read_live_trading_config,
|
|
read_llm_provider,
|
|
read_prompt_settings,
|
|
read_trading_settings,
|
|
)
|
|
from .exchanges import base_asset_for_symbol
|
|
from .live_trading import (
|
|
apply_symbol_settings,
|
|
cancel_all_open_orders,
|
|
fetch_account_snapshot,
|
|
live_execution_status,
|
|
normalize_quantity,
|
|
place_market_order,
|
|
place_protection_orders,
|
|
)
|
|
from .llm import generate_trading_decision, provider_status
|
|
from .market import (
|
|
build_candidate_snapshot,
|
|
candidate_universe_from_scan,
|
|
fetch_candidate_live_context,
|
|
fetch_market_backdrop,
|
|
read_latest_scan,
|
|
refresh_candidate_pool,
|
|
)
|
|
from .utils import DATA_DIR, clamp, current_run_date, now_iso, num, one_line, read_json, safe_last, write_json
|
|
|
|
|
|
STATE_PATH = DATA_DIR / "trading_agent_state.json"
|
|
DECISIONS_DIR = DATA_DIR / "trading-agent" / "decisions"
|
|
|
|
|
|
def clean_mode(value: Any) -> str:
|
|
return "live" if str(value or "paper").strip().lower() == "live" else "paper"
|
|
|
|
|
|
def account_key_for_mode(value: Any) -> str:
|
|
return "live" if clean_mode(value) == "live" else "paper"
|
|
|
|
|
|
def enabled_modes(settings: dict[str, Any]) -> list[str]:
|
|
modes: list[str] = []
|
|
if settings.get("paperTrading", {}).get("enabled"):
|
|
modes.append("paper")
|
|
if settings.get("liveTrading", {}).get("enabled"):
|
|
modes.append("live")
|
|
return modes
|
|
|
|
|
|
def empty_trading_account(initial_capital_usd: float, source: str) -> dict[str, Any]:
|
|
return {
|
|
"initialCapitalUsd": initial_capital_usd,
|
|
"accountSource": source,
|
|
"highWatermarkEquity": initial_capital_usd,
|
|
"sessionStartedAt": None,
|
|
"lastDecisionAt": None,
|
|
"circuitBreakerTripped": False,
|
|
"circuitBreakerReason": None,
|
|
"exchangeWalletBalanceUsd": None,
|
|
"exchangeEquityUsd": None,
|
|
"exchangeAvailableBalanceUsd": None,
|
|
"exchangeUnrealizedPnlUsd": None,
|
|
"exchangeNetCashflowUsd": None,
|
|
"exchangeIncomeRealizedPnlUsd": None,
|
|
"exchangeFundingFeeUsd": None,
|
|
"exchangeCommissionUsd": None,
|
|
"exchangeOtherIncomeUsd": None,
|
|
"exchangeAccountingUpdatedAt": None,
|
|
"exchangeAccountingNote": None,
|
|
"openPositions": [],
|
|
"openOrders": [],
|
|
"exchangeClosedTrades": [],
|
|
"closedTrades": [],
|
|
"decisions": [],
|
|
}
|
|
|
|
|
|
def default_state(settings: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
settings = settings or read_trading_settings()
|
|
return {
|
|
"version": 2,
|
|
"updatedAt": now_iso(),
|
|
"paper": empty_trading_account(settings["initialCapitalUsd"], "paper"),
|
|
"live": empty_trading_account(settings["initialCapitalUsd"], "exchange"),
|
|
"adaptive": {
|
|
"updatedAt": None,
|
|
"notes": [
|
|
"The Python build keeps execution logic local and uses the editable trade-logic fields only for trade judgment.",
|
|
"Paper mode and live mode can be started independently from the dashboard.",
|
|
],
|
|
},
|
|
}
|
|
|
|
|
|
def normalize_position(position: dict[str, Any]) -> dict[str, Any]:
|
|
side = "short" if str(position.get("side") or "long").lower() == "short" else "long"
|
|
symbol = str(position.get("symbol") or "").upper()
|
|
exchange_id = str(position.get("source") or "binance").strip().lower() or "binance"
|
|
quantity = num(position.get("quantity")) or 0
|
|
entry_price = num(position.get("entryPrice")) or 0
|
|
notional = num(position.get("notionalUsd")) or quantity * entry_price
|
|
return {
|
|
"id": str(position.get("id") or f"{symbol}-{int(__import__('time').time() * 1000)}"),
|
|
"symbol": symbol,
|
|
"baseAsset": str(position.get("baseAsset") or base_asset_for_symbol(symbol, exchange_id)),
|
|
"side": side,
|
|
"quantity": quantity,
|
|
"initialQuantity": num(position.get("initialQuantity")) or quantity,
|
|
"entryPrice": entry_price,
|
|
"notionalUsd": notional,
|
|
"initialNotionalUsd": num(position.get("initialNotionalUsd")) or notional,
|
|
"stopLoss": num(position.get("stopLoss")),
|
|
"takeProfit": num(position.get("takeProfit")),
|
|
"lastMarkPrice": num(position.get("lastMarkPrice")) or entry_price,
|
|
"lastMarkTime": position.get("lastMarkTime") or now_iso(),
|
|
"leverage": num(position.get("leverage")) or 1,
|
|
"status": "open",
|
|
"openedAt": position.get("openedAt"),
|
|
"updatedAt": position.get("updatedAt") or now_iso(),
|
|
"source": position.get("source") or "trading_agent",
|
|
"entryReason": position.get("entryReason") or "",
|
|
"decisionId": position.get("decisionId"),
|
|
"confidenceScore": num(position.get("confidenceScore")),
|
|
}
|
|
|
|
|
|
def normalize_trade(trade: dict[str, Any]) -> dict[str, Any]:
|
|
symbol = str(trade.get("symbol") or "").upper()
|
|
exchange_id = str(trade.get("source") or "binance").strip().lower() or "binance"
|
|
return {
|
|
"id": str(trade.get("id") or f"trade-{int(__import__('time').time() * 1000)}"),
|
|
"positionId": trade.get("positionId"),
|
|
"symbol": symbol,
|
|
"baseAsset": str(trade.get("baseAsset") or base_asset_for_symbol(symbol, exchange_id)),
|
|
"side": "short" if str(trade.get("side") or "long").lower() == "short" else "long",
|
|
"quantity": num(trade.get("quantity")) or 0,
|
|
"entryPrice": num(trade.get("entryPrice")) or 0,
|
|
"exitPrice": num(trade.get("exitPrice")) or 0,
|
|
"notionalUsd": num(trade.get("notionalUsd")) or 0,
|
|
"realizedPnl": num(trade.get("realizedPnl")) or 0,
|
|
"openedAt": trade.get("openedAt"),
|
|
"closedAt": trade.get("closedAt") or now_iso(),
|
|
"exitReason": trade.get("exitReason") or "manual",
|
|
"decisionId": trade.get("decisionId"),
|
|
}
|
|
|
|
|
|
def normalize_exchange_closed_trade(trade: dict[str, Any]) -> dict[str, Any]:
|
|
symbol = str(trade.get("symbol") or "").upper()
|
|
exchange_id = str(trade.get("source") or "binance").strip().lower() or "binance"
|
|
return {
|
|
"id": str(trade.get("id") or f"exchange-close-{int(__import__('time').time() * 1000)}"),
|
|
"symbol": symbol,
|
|
"baseAsset": str(trade.get("baseAsset") or base_asset_for_symbol(symbol, exchange_id)),
|
|
"realizedPnl": num(trade.get("realizedPnl")) or 0,
|
|
"asset": str(trade.get("asset") or "USDT").strip().upper() or "USDT",
|
|
"closedAt": trade.get("closedAt") or now_iso(),
|
|
"info": str(trade.get("info") or "").strip(),
|
|
"source": str(trade.get("source") or exchange_id),
|
|
}
|
|
|
|
|
|
def normalize_order(order: dict[str, Any]) -> dict[str, Any]:
|
|
symbol = str(order.get("symbol") or "").upper()
|
|
exchange_id = str(order.get("source") or "binance").strip().lower() or "binance"
|
|
return {
|
|
"id": str(order.get("id") or f"order-{int(__import__('time').time() * 1000)}"),
|
|
"symbol": symbol,
|
|
"baseAsset": str(order.get("baseAsset") or base_asset_for_symbol(symbol, exchange_id)),
|
|
"side": str(order.get("side") or "").upper(),
|
|
"positionSide": str(order.get("positionSide") or "").upper(),
|
|
"type": str(order.get("type") or "").upper(),
|
|
"status": str(order.get("status") or "").upper(),
|
|
"price": num(order.get("price")),
|
|
"triggerPrice": num(order.get("triggerPrice")),
|
|
"quantity": num(order.get("quantity")),
|
|
"reduceOnly": order.get("reduceOnly") is True,
|
|
"closePosition": order.get("closePosition") is True,
|
|
"workingType": str(order.get("workingType") or "").upper(),
|
|
"source": str(order.get("source") or exchange_id),
|
|
"updatedAt": order.get("updatedAt") or now_iso(),
|
|
}
|
|
|
|
|
|
def derive_session_started_at(book: dict[str, Any]) -> str | None:
|
|
candidates: list[str] = []
|
|
if book.get("sessionStartedAt"):
|
|
candidates.append(str(book.get("sessionStartedAt")))
|
|
if book.get("lastDecisionAt"):
|
|
candidates.append(str(book.get("lastDecisionAt")))
|
|
for decision in book.get("decisions", []):
|
|
if isinstance(decision, dict):
|
|
if decision.get("startedAt"):
|
|
candidates.append(str(decision.get("startedAt")))
|
|
if decision.get("finishedAt"):
|
|
candidates.append(str(decision.get("finishedAt")))
|
|
for trade in book.get("closedTrades", []):
|
|
if isinstance(trade, dict):
|
|
if trade.get("openedAt"):
|
|
candidates.append(str(trade.get("openedAt")))
|
|
if trade.get("closedAt"):
|
|
candidates.append(str(trade.get("closedAt")))
|
|
for position in book.get("openPositions", []):
|
|
if isinstance(position, dict) and position.get("openedAt"):
|
|
candidates.append(str(position.get("openedAt")))
|
|
|
|
parsed: list[tuple[float, str]] = []
|
|
for value in candidates:
|
|
try:
|
|
dt = __import__("datetime").datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except Exception:
|
|
continue
|
|
parsed.append((dt.timestamp(), value))
|
|
if not parsed:
|
|
return None
|
|
parsed.sort(key=lambda item: item[0])
|
|
return parsed[0][1]
|
|
|
|
|
|
def normalize_decision(decision: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"id": str(decision.get("id") or f"decision-{int(__import__('time').time() * 1000)}"),
|
|
"startedAt": decision.get("startedAt") or now_iso(),
|
|
"finishedAt": decision.get("finishedAt") or now_iso(),
|
|
"runnerReason": decision.get("runnerReason") or "manual",
|
|
"mode": clean_mode(decision.get("mode")),
|
|
"prompt": str(decision.get("prompt") or ""),
|
|
"promptSummary": str(decision.get("promptSummary") or ""),
|
|
"output": decision.get("output") if isinstance(decision.get("output"), dict) else {},
|
|
"rawModelResponse": decision.get("rawModelResponse") if isinstance(decision.get("rawModelResponse"), dict) else {},
|
|
"actions": decision.get("actions") if isinstance(decision.get("actions"), list) else [],
|
|
"warnings": decision.get("warnings") if isinstance(decision.get("warnings"), list) else [],
|
|
"candidateUniverse": decision.get("candidateUniverse") if isinstance(decision.get("candidateUniverse"), list) else [],
|
|
"accountBefore": decision.get("accountBefore") if isinstance(decision.get("accountBefore"), dict) else {},
|
|
"accountAfter": decision.get("accountAfter") if isinstance(decision.get("accountAfter"), dict) else {},
|
|
}
|
|
|
|
|
|
def read_trading_state(settings: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
settings = settings or read_trading_settings()
|
|
saved = read_json(STATE_PATH, {})
|
|
state = default_state(settings)
|
|
for key in ("paper", "live"):
|
|
source = "exchange" if key == "live" else "paper"
|
|
seed = saved.get(key) if isinstance(saved.get(key), dict) else {}
|
|
normalized = {
|
|
**empty_trading_account(settings["initialCapitalUsd"], source),
|
|
**seed,
|
|
"initialCapitalUsd": num(seed.get("initialCapitalUsd")) or settings["initialCapitalUsd"],
|
|
"accountSource": seed.get("accountSource") or source,
|
|
"highWatermarkEquity": num(seed.get("highWatermarkEquity")) or settings["initialCapitalUsd"],
|
|
"exchangeWalletBalanceUsd": num(seed.get("exchangeWalletBalanceUsd")),
|
|
"exchangeEquityUsd": num(seed.get("exchangeEquityUsd")),
|
|
"exchangeAvailableBalanceUsd": num(seed.get("exchangeAvailableBalanceUsd")),
|
|
"exchangeUnrealizedPnlUsd": num(seed.get("exchangeUnrealizedPnlUsd")),
|
|
"exchangeNetCashflowUsd": num(seed.get("exchangeNetCashflowUsd")),
|
|
"exchangeIncomeRealizedPnlUsd": num(seed.get("exchangeIncomeRealizedPnlUsd")),
|
|
"exchangeFundingFeeUsd": num(seed.get("exchangeFundingFeeUsd")),
|
|
"exchangeCommissionUsd": num(seed.get("exchangeCommissionUsd")),
|
|
"exchangeOtherIncomeUsd": num(seed.get("exchangeOtherIncomeUsd")),
|
|
"exchangeAccountingUpdatedAt": seed.get("exchangeAccountingUpdatedAt"),
|
|
"exchangeAccountingNote": seed.get("exchangeAccountingNote"),
|
|
"openPositions": [normalize_position(item) for item in seed.get("openPositions", [])],
|
|
"openOrders": [normalize_order(item) for item in seed.get("openOrders", [])],
|
|
"exchangeClosedTrades": [normalize_exchange_closed_trade(item) for item in seed.get("exchangeClosedTrades", [])],
|
|
"closedTrades": [normalize_trade(item) for item in seed.get("closedTrades", [])],
|
|
"decisions": [normalize_decision(item) for item in seed.get("decisions", [])],
|
|
}
|
|
state[key] = normalized
|
|
adaptive = saved.get("adaptive") if isinstance(saved.get("adaptive"), dict) else {}
|
|
state["adaptive"] = {
|
|
"updatedAt": adaptive.get("updatedAt"),
|
|
"notes": adaptive.get("notes") if isinstance(adaptive.get("notes"), list) else state["adaptive"]["notes"],
|
|
}
|
|
state["updatedAt"] = saved.get("updatedAt") or state["updatedAt"]
|
|
return state
|
|
|
|
|
|
def write_trading_state(state: dict[str, Any]) -> dict[str, Any]:
|
|
payload = deepcopy(state)
|
|
for key in ("paper", "live"):
|
|
payload[key]["openPositions"] = [normalize_position(item) for item in payload[key].get("openPositions", [])]
|
|
payload[key]["openOrders"] = [normalize_order(item) for item in payload[key].get("openOrders", [])][-80:]
|
|
payload[key]["exchangeClosedTrades"] = [normalize_exchange_closed_trade(item) for item in payload[key].get("exchangeClosedTrades", [])][-400:]
|
|
payload[key]["closedTrades"] = [normalize_trade(item) for item in payload[key].get("closedTrades", [])][-400:]
|
|
payload[key]["decisions"] = [normalize_decision(item) for item in payload[key].get("decisions", [])][-40:]
|
|
payload["updatedAt"] = now_iso()
|
|
write_json(STATE_PATH, payload)
|
|
return payload
|
|
|
|
|
|
def archive_decision(decision: dict[str, Any]) -> None:
|
|
run_date = current_run_date()
|
|
path = DECISIONS_DIR / run_date / f"{decision['id']}.json"
|
|
write_json(path, decision)
|
|
|
|
|
|
def position_pnl(position: dict[str, Any], mark_price: float | None) -> float | None:
|
|
entry_price = num(position.get("entryPrice"))
|
|
quantity = num(position.get("quantity"))
|
|
mark = num(mark_price)
|
|
if entry_price is None or quantity is None or mark is None:
|
|
return None
|
|
multiplier = -1 if position.get("side") == "short" else 1
|
|
return (mark - entry_price) * quantity * multiplier
|
|
|
|
|
|
def enrich_position(position: dict[str, Any]) -> dict[str, Any]:
|
|
mark_price = num(position.get("lastMarkPrice")) or num(position.get("entryPrice")) or 0
|
|
unrealized_pnl = position_pnl(position, mark_price) or 0
|
|
notional_usd = num(position.get("notionalUsd")) or (mark_price * (num(position.get("quantity")) or 0))
|
|
pnl_pct = (unrealized_pnl / notional_usd) * 100 if notional_usd else None
|
|
enriched = dict(position)
|
|
enriched["markPrice"] = mark_price
|
|
enriched["unrealizedPnl"] = unrealized_pnl
|
|
enriched["pnlPct"] = pnl_pct
|
|
return enriched
|
|
|
|
|
|
def summarize_account(book: dict[str, Any], settings: dict[str, Any]) -> dict[str, Any]:
|
|
open_positions = [enrich_position(item) for item in book.get("openPositions", [])]
|
|
open_orders = [normalize_order(item) for item in book.get("openOrders", [])]
|
|
exchange_closed_trades = [normalize_exchange_closed_trade(item) for item in book.get("exchangeClosedTrades", [])]
|
|
local_estimated_realized_pnl = sum(num(item.get("realizedPnl")) or 0 for item in book.get("closedTrades", []))
|
|
unrealized_pnl = sum(num(item.get("unrealizedPnl")) or 0 for item in open_positions)
|
|
initial_capital = num(book.get("initialCapitalUsd")) or settings["initialCapitalUsd"]
|
|
account_source = book.get("accountSource") or "paper"
|
|
equity_usd = (num(book.get("exchangeEquityUsd")) if account_source == "exchange" else None)
|
|
if equity_usd is None:
|
|
equity_usd = initial_capital + local_estimated_realized_pnl + unrealized_pnl
|
|
has_local_history = bool(book.get("decisions") or book.get("closedTrades"))
|
|
if account_source == "exchange" and not has_local_history and equity_usd is not None:
|
|
initial_capital = equity_usd
|
|
exchange_wallet_balance = num(book.get("exchangeWalletBalanceUsd"))
|
|
exchange_unrealized_pnl = num(book.get("exchangeUnrealizedPnlUsd"))
|
|
exchange_net_cashflow_usd = num(book.get("exchangeNetCashflowUsd"))
|
|
exchange_realized_pnl_usd = None
|
|
if account_source == "exchange":
|
|
exchange_realized_pnl_usd = sum(num(item.get("realizedPnl")) or 0 for item in exchange_closed_trades)
|
|
realized_pnl_usd = exchange_realized_pnl_usd if account_source == "exchange" and exchange_realized_pnl_usd is not None else local_estimated_realized_pnl
|
|
gross_exposure = sum(abs((num(item.get("markPrice")) or 0) * (num(item.get("quantity")) or 0)) for item in open_positions)
|
|
max_gross_exposure = equity_usd * (settings["maxGrossExposurePct"] / 100)
|
|
available_exposure = max(0.0, max_gross_exposure - gross_exposure)
|
|
if account_source == "exchange" and not has_local_history and equity_usd is not None:
|
|
high_watermark = equity_usd
|
|
else:
|
|
high_watermark = max(num(book.get("highWatermarkEquity")) or initial_capital, equity_usd)
|
|
drawdown_pct = ((high_watermark - equity_usd) / high_watermark) * 100 if high_watermark else 0
|
|
return {
|
|
"baselineCapitalUsd": initial_capital,
|
|
"initialCapitalUsd": initial_capital,
|
|
"equityUsd": equity_usd,
|
|
"realizedPnlUsd": realized_pnl_usd,
|
|
"localEstimatedRealizedPnlUsd": local_estimated_realized_pnl,
|
|
"exchangeRealizedPnlUsd": exchange_realized_pnl_usd,
|
|
"exchangeNetCashflowUsd": exchange_net_cashflow_usd,
|
|
"exchangeIncomeRealizedPnlUsd": num(book.get("exchangeIncomeRealizedPnlUsd")),
|
|
"exchangeFundingFeeUsd": num(book.get("exchangeFundingFeeUsd")),
|
|
"exchangeCommissionUsd": num(book.get("exchangeCommissionUsd")),
|
|
"exchangeOtherIncomeUsd": num(book.get("exchangeOtherIncomeUsd")),
|
|
"exchangeAccountingUpdatedAt": book.get("exchangeAccountingUpdatedAt"),
|
|
"exchangeAccountingNote": book.get("exchangeAccountingNote"),
|
|
"unrealizedPnlUsd": unrealized_pnl,
|
|
"highWatermarkEquity": high_watermark,
|
|
"drawdownPct": drawdown_pct,
|
|
"grossExposureUsd": gross_exposure,
|
|
"maxGrossExposureUsd": max_gross_exposure,
|
|
"availableExposureUsd": available_exposure,
|
|
"exchangeWalletBalanceUsd": exchange_wallet_balance,
|
|
"exchangeAvailableBalanceUsd": num(book.get("exchangeAvailableBalanceUsd")),
|
|
"exchangeUnrealizedPnlUsd": exchange_unrealized_pnl,
|
|
"exchangeClosedTradesCount": len(exchange_closed_trades),
|
|
"openPositions": open_positions,
|
|
"openOrdersCount": len(open_orders),
|
|
"closedTradesCount": len(book.get("closedTrades", [])),
|
|
"decisionsCount": len(book.get("decisions", [])),
|
|
"circuitBreakerTripped": book.get("circuitBreakerTripped") is True,
|
|
"circuitBreakerReason": book.get("circuitBreakerReason"),
|
|
"accountSource": account_source,
|
|
}
|
|
|
|
|
|
def action_label(action_type: str, symbol: str | None = None, side: str | None = None) -> str:
|
|
symbol = symbol or "MARKET"
|
|
if action_type == "open":
|
|
return f"{(side or '').upper()} {symbol}".strip()
|
|
if action_type == "close":
|
|
return f"Close {symbol}"
|
|
if action_type == "reduce":
|
|
return f"Reduce {symbol}"
|
|
if action_type == "update":
|
|
return f"Update risk {symbol}"
|
|
if action_type == "circuit_breaker":
|
|
return "Circuit breaker"
|
|
return action_type
|
|
|
|
|
|
def serialize_candidate_for_history(candidate: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"symbol": candidate.get("symbol"),
|
|
"baseAsset": candidate.get("baseAsset"),
|
|
"price": candidate.get("price"),
|
|
}
|
|
|
|
|
|
def serialize_candidate_for_prompt(candidate: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
**serialize_candidate_for_history(candidate),
|
|
"priceChangePct": candidate.get("priceChangePct"),
|
|
"quoteVolume": candidate.get("quoteVolume"),
|
|
"fundingPct": candidate.get("fundingPct"),
|
|
"klineFeeds": candidate.get("klineFeeds"),
|
|
"klinesByInterval": candidate.get("klinesByInterval"),
|
|
}
|
|
|
|
|
|
def close_position(book: dict[str, Any], position: dict[str, Any], exit_price: float, decision_id: str, reason: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
trade = normalize_trade(
|
|
{
|
|
"id": f"{position['id']}-close-{int(__import__('time').time() * 1000)}",
|
|
"positionId": position["id"],
|
|
"symbol": position["symbol"],
|
|
"baseAsset": position["baseAsset"],
|
|
"side": position["side"],
|
|
"quantity": position["quantity"],
|
|
"entryPrice": position["entryPrice"],
|
|
"exitPrice": exit_price,
|
|
"notionalUsd": position.get("notionalUsd"),
|
|
"realizedPnl": position_pnl(position, exit_price) or 0,
|
|
"openedAt": position.get("openedAt"),
|
|
"closedAt": now_iso(),
|
|
"exitReason": reason,
|
|
"decisionId": decision_id,
|
|
}
|
|
)
|
|
book["openPositions"] = [item for item in book.get("openPositions", []) if item["id"] != position["id"]]
|
|
book.setdefault("closedTrades", []).append(trade)
|
|
action = {
|
|
"type": "close",
|
|
"symbol": position["symbol"],
|
|
"side": position["side"],
|
|
"realizedPnlUsd": trade["realizedPnl"],
|
|
"reason": reason,
|
|
"label": action_label("close", position["symbol"]),
|
|
}
|
|
return book, action
|
|
|
|
|
|
def reduce_position(book: dict[str, Any], position: dict[str, Any], exit_price: float, reduce_fraction: float, decision_id: str, reason: str) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
|
total_qty = num(position.get("quantity")) or 0
|
|
fraction = clamp(reduce_fraction, 0.05, 0.95)
|
|
close_qty = total_qty * fraction
|
|
remaining_qty = total_qty - close_qty
|
|
if remaining_qty <= 1e-9:
|
|
return close_position(book, position, exit_price, decision_id, reason)
|
|
partial_position = dict(position)
|
|
partial_position["quantity"] = close_qty
|
|
trade = normalize_trade(
|
|
{
|
|
"id": f"{position['id']}-reduce-{int(__import__('time').time() * 1000)}",
|
|
"positionId": position["id"],
|
|
"symbol": position["symbol"],
|
|
"baseAsset": position["baseAsset"],
|
|
"side": position["side"],
|
|
"quantity": close_qty,
|
|
"entryPrice": position["entryPrice"],
|
|
"exitPrice": exit_price,
|
|
"notionalUsd": (num(position.get("notionalUsd")) or 0) * fraction,
|
|
"realizedPnl": position_pnl(partial_position, exit_price) or 0,
|
|
"openedAt": position.get("openedAt"),
|
|
"closedAt": now_iso(),
|
|
"exitReason": reason,
|
|
"decisionId": decision_id,
|
|
}
|
|
)
|
|
for index, current in enumerate(book.get("openPositions", [])):
|
|
if current["id"] != position["id"]:
|
|
continue
|
|
updated = dict(current)
|
|
updated["quantity"] = remaining_qty
|
|
updated["notionalUsd"] = (num(current.get("notionalUsd")) or 0) * (remaining_qty / total_qty)
|
|
updated["updatedAt"] = now_iso()
|
|
book["openPositions"][index] = normalize_position(updated)
|
|
break
|
|
book.setdefault("closedTrades", []).append(trade)
|
|
action = {
|
|
"type": "reduce",
|
|
"symbol": position["symbol"],
|
|
"side": position["side"],
|
|
"reduceFraction": fraction,
|
|
"realizedPnlUsd": trade["realizedPnl"],
|
|
"reason": reason,
|
|
"label": action_label("reduce", position["symbol"]),
|
|
}
|
|
return book, action
|
|
|
|
|
|
def build_prompt(
|
|
*,
|
|
settings: dict[str, Any],
|
|
prompt_settings: dict[str, Any],
|
|
provider: dict[str, Any],
|
|
market_backdrop: dict[str, Any],
|
|
account_summary: dict[str, Any],
|
|
open_positions: list[dict[str, Any]],
|
|
open_orders: list[dict[str, Any]],
|
|
candidates: list[dict[str, Any]],
|
|
) -> str:
|
|
response_contract = {
|
|
"summary": "short plain-language summary",
|
|
"position_actions": [
|
|
{
|
|
"symbol": "BTCUSDT",
|
|
"decision": "hold | close | reduce | update",
|
|
"reason": "short reason",
|
|
"reduceFraction": 0.25,
|
|
"stopLoss": 0.0,
|
|
"takeProfit": 0.0,
|
|
}
|
|
],
|
|
"entry_actions": [
|
|
{
|
|
"symbol": "ETHUSDT",
|
|
"action": "open",
|
|
"side": "long | short",
|
|
"confidence": 72,
|
|
"reason": "short reason",
|
|
"stopLoss": 0.0,
|
|
"takeProfit": 0.0,
|
|
}
|
|
],
|
|
"watchlist": [
|
|
{
|
|
"symbol": "SOLUSDT",
|
|
"reason": "why it is worth watching",
|
|
}
|
|
],
|
|
}
|
|
context = {
|
|
"timestamp": now_iso(),
|
|
"mode": settings["mode"],
|
|
"provider": {
|
|
"preset": provider["preset"],
|
|
"apiStyle": provider["apiStyle"],
|
|
"model": provider["model"],
|
|
},
|
|
"hardRiskLimits": {
|
|
"maxNewPositionsPerCycle": settings["maxNewPositionsPerCycle"],
|
|
"maxOpenPositions": settings["maxOpenPositions"],
|
|
"maxPositionNotionalUsd": settings["maxPositionNotionalUsd"],
|
|
"maxGrossExposurePct": settings["maxGrossExposurePct"],
|
|
"maxAccountDrawdownPct": settings["maxAccountDrawdownPct"],
|
|
"riskPerTradePct": settings["riskPerTradePct"],
|
|
"minConfidence": settings["minConfidence"],
|
|
"allowShorts": settings["allowShorts"],
|
|
},
|
|
"marketBackdrop": market_backdrop,
|
|
"account": account_summary,
|
|
"openPositions": open_positions,
|
|
"openOrders": open_orders,
|
|
"candidates": [serialize_candidate_for_prompt(item) for item in candidates],
|
|
}
|
|
rules = [
|
|
"Manage every existing position first. Existing positions should appear in position_actions.",
|
|
"Respect existing exchange open orders and avoid duplicating protection logic that is already active.",
|
|
f"You may propose at most {settings['maxNewPositionsPerCycle']} new entries.",
|
|
"Do not propose entries for symbols that are not in candidates.",
|
|
"Respect the hard risk limits from the system context even if the user logic asks for more.",
|
|
"If there is no clear edge, return empty entry_actions.",
|
|
"Return strict JSON only. No markdown, no prose outside the JSON object.",
|
|
]
|
|
return "\n".join(
|
|
[
|
|
"# Editable Trading Logic JSON",
|
|
json.dumps(prompt_settings["decision_logic"], ensure_ascii=False, indent=2),
|
|
"",
|
|
"# System Rules",
|
|
*[f"- {rule}" for rule in rules],
|
|
"",
|
|
"# Required JSON Contract",
|
|
json.dumps(response_contract, ensure_ascii=False, indent=2),
|
|
"",
|
|
"# Current Trading Context",
|
|
json.dumps(context, ensure_ascii=False, indent=2),
|
|
]
|
|
)
|
|
|
|
|
|
def default_model_decision(open_positions: list[dict[str, Any]]) -> dict[str, Any]:
|
|
return {
|
|
"summary": "Fallback decision because model output was unavailable.",
|
|
"position_actions": [
|
|
{
|
|
"symbol": position["symbol"],
|
|
"decision": "hold",
|
|
"reason": "Fallback hold because model output was unavailable.",
|
|
}
|
|
for position in open_positions
|
|
],
|
|
"entry_actions": [],
|
|
"watchlist": [],
|
|
}
|
|
|
|
|
|
def normalize_model_decision(
|
|
parsed: dict[str, Any],
|
|
*,
|
|
open_positions: list[dict[str, Any]],
|
|
candidates_by_symbol: dict[str, dict[str, Any]],
|
|
) -> dict[str, Any]:
|
|
if not isinstance(parsed, dict):
|
|
raise ValueError("Model response must be a JSON object.")
|
|
position_actions_raw = parsed.get("position_actions") if isinstance(parsed.get("position_actions"), list) else []
|
|
entry_actions_raw = parsed.get("entry_actions") if isinstance(parsed.get("entry_actions"), list) else []
|
|
watchlist_raw = parsed.get("watchlist") if isinstance(parsed.get("watchlist"), list) else []
|
|
positions_by_symbol = {item["symbol"]: item for item in open_positions}
|
|
normalized_positions: list[dict[str, Any]] = []
|
|
seen_symbols: set[str] = set()
|
|
for item in position_actions_raw:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
symbol = str(item.get("symbol") or "").upper()
|
|
if symbol not in positions_by_symbol or symbol in seen_symbols:
|
|
continue
|
|
decision = str(item.get("decision") or "hold").strip().lower()
|
|
if decision not in {"hold", "close", "reduce", "update"}:
|
|
decision = "hold"
|
|
normalized_positions.append(
|
|
{
|
|
"symbol": symbol,
|
|
"decision": decision,
|
|
"reason": str(item.get("reason") or ""),
|
|
"reduceFraction": clamp(item.get("reduceFraction"), 0.05, 0.95),
|
|
"stopLoss": num(item.get("stopLoss")),
|
|
"takeProfit": num(item.get("takeProfit")),
|
|
}
|
|
)
|
|
seen_symbols.add(symbol)
|
|
for symbol in positions_by_symbol:
|
|
if symbol not in seen_symbols:
|
|
normalized_positions.append(
|
|
{
|
|
"symbol": symbol,
|
|
"decision": "hold",
|
|
"reason": "No explicit model instruction; defaulting to hold.",
|
|
"reduceFraction": 0.25,
|
|
"stopLoss": None,
|
|
"takeProfit": None,
|
|
}
|
|
)
|
|
normalized_entries: list[dict[str, Any]] = []
|
|
for item in entry_actions_raw:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
symbol = str(item.get("symbol") or "").upper()
|
|
candidate = candidates_by_symbol.get(symbol)
|
|
if not candidate:
|
|
continue
|
|
action = str(item.get("action") or "open").strip().lower()
|
|
if action != "open":
|
|
continue
|
|
side = str(item.get("side") or candidate.get("defaultSide") or "").strip().lower()
|
|
if side not in {"long", "short"}:
|
|
continue
|
|
normalized_entries.append(
|
|
{
|
|
"symbol": symbol,
|
|
"action": "open",
|
|
"side": side,
|
|
"confidence": clamp(item.get("confidence") or candidate.get("confidenceScore"), 1, 100),
|
|
"reason": str(item.get("reason") or candidate.get("topStrategy") or ""),
|
|
"stopLoss": num(item.get("stopLoss")) or num(candidate.get("defaultStopLoss")),
|
|
"takeProfit": num(item.get("takeProfit")) or num(candidate.get("defaultTakeProfit")),
|
|
}
|
|
)
|
|
normalized_watchlist = []
|
|
for item in watchlist_raw:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
symbol = str(item.get("symbol") or "").upper()
|
|
if not symbol:
|
|
continue
|
|
normalized_watchlist.append(
|
|
{
|
|
"symbol": symbol,
|
|
"reason": str(item.get("reason") or ""),
|
|
}
|
|
)
|
|
return {
|
|
"summary": str(parsed.get("summary") or ""),
|
|
"position_actions": normalized_positions,
|
|
"entry_actions": normalized_entries,
|
|
"watchlist": normalized_watchlist,
|
|
}
|
|
|
|
|
|
def mark_to_market(book: dict[str, Any], live_by_symbol: dict[str, dict[str, Any]]) -> None:
|
|
for position in book.get("openPositions", []):
|
|
live = live_by_symbol.get(position["symbol"])
|
|
if not live:
|
|
continue
|
|
mark_price = num(live["premium"].get("markPrice")) or num(live["ticker24h"].get("lastPrice")) or num(position.get("entryPrice")) or 0
|
|
position["lastMarkPrice"] = mark_price
|
|
position["lastMarkTime"] = now_iso()
|
|
position["updatedAt"] = now_iso()
|
|
|
|
|
|
def _risk_valid_for_side(side: str, mark_price: float, stop_loss: float | None, take_profit: float | None) -> bool:
|
|
if side == "long":
|
|
if stop_loss is not None and stop_loss >= mark_price:
|
|
return False
|
|
if take_profit is not None and take_profit <= mark_price:
|
|
return False
|
|
else:
|
|
if stop_loss is not None and stop_loss <= mark_price:
|
|
return False
|
|
if take_profit is not None and take_profit >= mark_price:
|
|
return False
|
|
return True
|
|
|
|
|
|
def apply_protection_hits(book: dict[str, Any], decision_id: str) -> list[dict[str, Any]]:
|
|
actions: list[dict[str, Any]] = []
|
|
for position in list(book.get("openPositions", [])):
|
|
mark_price = num(position.get("lastMarkPrice"))
|
|
if mark_price is None:
|
|
continue
|
|
stop_loss = num(position.get("stopLoss"))
|
|
take_profit = num(position.get("takeProfit"))
|
|
if position["side"] == "long":
|
|
if stop_loss is not None and mark_price <= stop_loss:
|
|
book, action = close_position(book, position, mark_price, decision_id, "stop_loss_hit")
|
|
actions.append(action)
|
|
continue
|
|
if take_profit is not None and mark_price >= take_profit:
|
|
book, action = close_position(book, position, mark_price, decision_id, "take_profit_hit")
|
|
actions.append(action)
|
|
continue
|
|
else:
|
|
if stop_loss is not None and mark_price >= stop_loss:
|
|
book, action = close_position(book, position, mark_price, decision_id, "stop_loss_hit")
|
|
actions.append(action)
|
|
continue
|
|
if take_profit is not None and mark_price <= take_profit:
|
|
book, action = close_position(book, position, mark_price, decision_id, "take_profit_hit")
|
|
actions.append(action)
|
|
continue
|
|
return actions
|
|
|
|
|
|
def position_notional_from_risk(
|
|
account_summary: dict[str, Any],
|
|
*,
|
|
entry_price: float,
|
|
stop_loss: float,
|
|
settings: dict[str, Any],
|
|
) -> float:
|
|
stop_pct = abs(((entry_price - stop_loss) / entry_price))
|
|
if stop_pct <= 0:
|
|
return 0
|
|
risk_budget = account_summary["equityUsd"] * (settings["riskPerTradePct"] / 100)
|
|
risk_sized_notional = risk_budget / stop_pct
|
|
return min(
|
|
settings["maxPositionNotionalUsd"],
|
|
account_summary["availableExposureUsd"],
|
|
risk_sized_notional,
|
|
)
|
|
|
|
|
|
def cap_live_notional_by_margin(
|
|
requested_notional_usd: float,
|
|
*,
|
|
account_summary: dict[str, Any],
|
|
live_config: dict[str, Any],
|
|
) -> float:
|
|
available_balance = num(account_summary.get("exchangeAvailableBalanceUsd"))
|
|
leverage = int(clamp(live_config.get("defaultLeverage"), 1, 125))
|
|
if available_balance is None or available_balance <= 0:
|
|
return requested_notional_usd
|
|
max_margin_notional = max(0.0, available_balance * leverage * 0.92)
|
|
return min(requested_notional_usd, max_margin_notional)
|
|
|
|
|
|
def open_paper_position(
|
|
book: dict[str, Any],
|
|
*,
|
|
candidate: dict[str, Any],
|
|
side: str,
|
|
stop_loss: float,
|
|
take_profit: float | None,
|
|
confidence: float,
|
|
notional_usd: float,
|
|
reason: str,
|
|
decision_id: str,
|
|
) -> dict[str, Any]:
|
|
entry_price = num(candidate.get("price")) or 0
|
|
quantity = notional_usd / entry_price if entry_price else 0
|
|
position = normalize_position(
|
|
{
|
|
"id": f"{candidate['symbol']}-{int(__import__('time').time() * 1000)}",
|
|
"symbol": candidate["symbol"],
|
|
"baseAsset": candidate["baseAsset"],
|
|
"side": side,
|
|
"quantity": quantity,
|
|
"initialQuantity": quantity,
|
|
"entryPrice": entry_price,
|
|
"notionalUsd": notional_usd,
|
|
"initialNotionalUsd": notional_usd,
|
|
"stopLoss": stop_loss,
|
|
"takeProfit": take_profit,
|
|
"lastMarkPrice": entry_price,
|
|
"lastMarkTime": now_iso(),
|
|
"leverage": 1,
|
|
"openedAt": now_iso(),
|
|
"updatedAt": now_iso(),
|
|
"source": "paper",
|
|
"entryReason": reason,
|
|
"decisionId": decision_id,
|
|
"confidenceScore": confidence,
|
|
}
|
|
)
|
|
book.setdefault("openPositions", []).append(position)
|
|
return position
|
|
|
|
|
|
def sync_live_book(
|
|
book: dict[str, Any],
|
|
settings: dict[str, Any],
|
|
) -> tuple[dict[str, Any], list[str], dict[str, Any], dict[str, Any] | None]:
|
|
live_config = read_live_trading_config()
|
|
status = live_execution_status(live_config, settings)
|
|
warnings: list[str] = []
|
|
if not status["canSync"]:
|
|
warnings.extend(status["issues"])
|
|
return book, warnings, status, live_config
|
|
session_started_at = book.get("sessionStartedAt") or derive_session_started_at(book)
|
|
if session_started_at:
|
|
book["sessionStartedAt"] = session_started_at
|
|
snapshot = fetch_account_snapshot(live_config, session_started_at=session_started_at)
|
|
accounting_note = str(snapshot.get("accountingNote") or "").strip()
|
|
if accounting_note:
|
|
warnings.append(accounting_note)
|
|
prior_positions = {item["symbol"]: item for item in book.get("openPositions", [])}
|
|
merged_positions = []
|
|
for position in snapshot["openPositions"]:
|
|
prior = prior_positions.get(position["symbol"], {})
|
|
merged = normalize_position(
|
|
{
|
|
**position,
|
|
"stopLoss": prior.get("stopLoss"),
|
|
"takeProfit": prior.get("takeProfit"),
|
|
"openedAt": prior.get("openedAt") or now_iso(),
|
|
"entryReason": prior.get("entryReason") or "synced_from_exchange",
|
|
"decisionId": prior.get("decisionId"),
|
|
}
|
|
)
|
|
merged_positions.append(merged)
|
|
merged_orders = [normalize_order(item) for item in snapshot.get("openOrders", [])]
|
|
exchange_closed_trades = [normalize_exchange_closed_trade(item) for item in snapshot.get("exchangeClosedTrades", [])]
|
|
should_seed_equity_baseline = not book.get("decisions") and not book.get("closedTrades")
|
|
snapshot_equity = num(snapshot.get("equityUsd"))
|
|
book.update(
|
|
{
|
|
"accountSource": "exchange",
|
|
"exchangeWalletBalanceUsd": snapshot["walletBalanceUsd"],
|
|
"exchangeEquityUsd": snapshot["equityUsd"],
|
|
"exchangeAvailableBalanceUsd": snapshot["availableBalanceUsd"],
|
|
"exchangeUnrealizedPnlUsd": snapshot["unrealizedPnlUsd"],
|
|
"exchangeNetCashflowUsd": num(snapshot.get("netCashflowUsd")),
|
|
"exchangeIncomeRealizedPnlUsd": num(snapshot.get("incomeRealizedPnlUsd")),
|
|
"exchangeFundingFeeUsd": num(snapshot.get("fundingFeeUsd")),
|
|
"exchangeCommissionUsd": num(snapshot.get("commissionUsd")),
|
|
"exchangeOtherIncomeUsd": num(snapshot.get("otherIncomeUsd")),
|
|
"exchangeAccountingUpdatedAt": snapshot.get("accountingUpdatedAt"),
|
|
"exchangeAccountingNote": snapshot.get("accountingNote"),
|
|
"openPositions": merged_positions,
|
|
"openOrders": merged_orders,
|
|
"exchangeClosedTrades": exchange_closed_trades,
|
|
}
|
|
)
|
|
if snapshot_equity is not None:
|
|
current_initial = num(book.get("initialCapitalUsd"))
|
|
current_high_watermark = num(book.get("highWatermarkEquity"))
|
|
if current_initial is None or current_initial <= 0:
|
|
book["initialCapitalUsd"] = snapshot_equity
|
|
if should_seed_equity_baseline or current_high_watermark is None or current_high_watermark <= 0:
|
|
book["highWatermarkEquity"] = snapshot_equity
|
|
return book, warnings, status, live_config
|
|
|
|
|
|
def refresh_account_state_after_settings_save(*, reset_live_session: bool = False) -> dict[str, Any]:
|
|
settings = read_trading_settings()
|
|
state = read_trading_state(settings)
|
|
|
|
paper_has_history = bool(state["paper"].get("decisions") or state["paper"].get("closedTrades"))
|
|
if not paper_has_history:
|
|
state["paper"]["initialCapitalUsd"] = settings["initialCapitalUsd"]
|
|
if not state["paper"].get("openPositions"):
|
|
state["paper"]["highWatermarkEquity"] = settings["initialCapitalUsd"]
|
|
|
|
live_has_history = bool(state["live"].get("decisions") or state["live"].get("closedTrades"))
|
|
if not live_has_history:
|
|
state["live"]["initialCapitalUsd"] = settings["initialCapitalUsd"]
|
|
if not state["live"].get("openPositions"):
|
|
state["live"]["highWatermarkEquity"] = settings["initialCapitalUsd"]
|
|
if reset_live_session:
|
|
state["live"]["sessionStartedAt"] = now_iso()
|
|
state["live"]["exchangeClosedTrades"] = []
|
|
elif settings.get("liveTrading", {}).get("enabled") and not state["live"].get("sessionStartedAt"):
|
|
state["live"]["sessionStartedAt"] = now_iso()
|
|
|
|
live_sync_warnings: list[str] = []
|
|
live_status_payload: dict[str, Any] | None = None
|
|
live_config: dict[str, Any] | None = None
|
|
try:
|
|
state["live"], live_sync_warnings, live_status_payload, live_config = sync_live_book(state["live"], settings)
|
|
except Exception as error:
|
|
live_sync_warnings = [f"Live account sync after settings save failed: {error}"]
|
|
|
|
write_trading_state(state)
|
|
return {
|
|
"state": state,
|
|
"liveSyncWarnings": live_sync_warnings,
|
|
"liveStatus": live_status_payload,
|
|
"liveConfig": live_config,
|
|
}
|
|
|
|
|
|
def apply_live_position_action(
|
|
book: dict[str, Any],
|
|
position: dict[str, Any],
|
|
action: dict[str, Any],
|
|
decision_id: str,
|
|
status: dict[str, Any],
|
|
live_config: dict[str, Any],
|
|
settings: dict[str, Any],
|
|
) -> tuple[dict[str, Any], list[dict[str, Any]], list[str]]:
|
|
actions: list[dict[str, Any]] = []
|
|
warnings: list[str] = []
|
|
decision = action["decision"]
|
|
mark_price = num(position.get("lastMarkPrice")) or num(position.get("entryPrice")) or 0
|
|
if decision in {"close", "reduce"} and not status["canExecute"]:
|
|
warnings.append(f"Live execution skipped for {position['symbol']}: real execution is not enabled.")
|
|
return book, actions, warnings
|
|
if decision == "close":
|
|
cancel_all_open_orders(live_config, position["symbol"])
|
|
order_side = "SELL" if position["side"] == "long" else "BUY"
|
|
place_market_order(live_config, symbol=position["symbol"], side=order_side, quantity=position["quantity"], reduce_only=True)
|
|
book, recorded = close_position(book, position, mark_price, decision_id, action["reason"] or "model_close")
|
|
recorded["exchange"] = True
|
|
actions.append(recorded)
|
|
return book, actions, warnings
|
|
if decision == "reduce":
|
|
close_qty = (num(position.get("quantity")) or 0) * action["reduceFraction"]
|
|
normalized_qty = normalize_quantity(live_config, position["symbol"], quantity=close_qty, reference_price=mark_price)
|
|
order_side = "SELL" if position["side"] == "long" else "BUY"
|
|
place_market_order(live_config, symbol=position["symbol"], side=order_side, quantity=normalized_qty, reduce_only=True)
|
|
book, recorded = reduce_position(book, position, mark_price, action["reduceFraction"], decision_id, action["reason"] or "model_reduce")
|
|
if recorded:
|
|
recorded["exchange"] = True
|
|
actions.append(recorded)
|
|
return book, actions, warnings
|
|
if decision in {"hold", "update"}:
|
|
stop_loss = action.get("stopLoss")
|
|
take_profit = action.get("takeProfit")
|
|
if stop_loss is None and take_profit is None:
|
|
return book, actions, warnings
|
|
if not _risk_valid_for_side(position["side"], mark_price, stop_loss, take_profit):
|
|
warnings.append(f"Ignored invalid live protection update for {position['symbol']}.")
|
|
return book, actions, warnings
|
|
for current in book.get("openPositions", []):
|
|
if current["id"] != position["id"]:
|
|
continue
|
|
current["stopLoss"] = stop_loss
|
|
current["takeProfit"] = take_profit
|
|
current["updatedAt"] = now_iso()
|
|
break
|
|
if status["canExecute"] and settings["liveExecution"]["useExchangeProtectionOrders"]:
|
|
try:
|
|
cancel_all_open_orders(live_config, position["symbol"])
|
|
place_protection_orders(
|
|
live_config,
|
|
symbol=position["symbol"],
|
|
position_side=position["side"],
|
|
stop_loss=stop_loss,
|
|
take_profit=take_profit,
|
|
)
|
|
except Exception as error:
|
|
warnings.append(f"Exchange protection order update failed for {position['symbol']}: {error}")
|
|
actions.append(
|
|
{
|
|
"type": "update",
|
|
"symbol": position["symbol"],
|
|
"side": position["side"],
|
|
"stopLoss": stop_loss,
|
|
"takeProfit": take_profit,
|
|
"reason": action["reason"] or "model_update",
|
|
"label": action_label("update", position["symbol"]),
|
|
}
|
|
)
|
|
return book, actions, warnings
|
|
|
|
|
|
def apply_paper_position_action(
|
|
book: dict[str, Any],
|
|
position: dict[str, Any],
|
|
action: dict[str, Any],
|
|
decision_id: str,
|
|
) -> tuple[dict[str, Any], list[dict[str, Any]], list[str]]:
|
|
actions: list[dict[str, Any]] = []
|
|
warnings: list[str] = []
|
|
mark_price = num(position.get("lastMarkPrice")) or num(position.get("entryPrice")) or 0
|
|
decision = action["decision"]
|
|
if decision == "close":
|
|
book, recorded = close_position(book, position, mark_price, decision_id, action["reason"] or "model_close")
|
|
actions.append(recorded)
|
|
return book, actions, warnings
|
|
if decision == "reduce":
|
|
book, recorded = reduce_position(book, position, mark_price, action["reduceFraction"], decision_id, action["reason"] or "model_reduce")
|
|
if recorded:
|
|
actions.append(recorded)
|
|
return book, actions, warnings
|
|
stop_loss = action.get("stopLoss")
|
|
take_profit = action.get("takeProfit")
|
|
if decision in {"hold", "update"} and (stop_loss is not None or take_profit is not None):
|
|
if not _risk_valid_for_side(position["side"], mark_price, stop_loss, take_profit):
|
|
warnings.append(f"Ignored invalid risk update for {position['symbol']}.")
|
|
return book, actions, warnings
|
|
for current in book.get("openPositions", []):
|
|
if current["id"] != position["id"]:
|
|
continue
|
|
current["stopLoss"] = stop_loss
|
|
current["takeProfit"] = take_profit
|
|
current["updatedAt"] = now_iso()
|
|
break
|
|
actions.append(
|
|
{
|
|
"type": "update",
|
|
"symbol": position["symbol"],
|
|
"side": position["side"],
|
|
"stopLoss": stop_loss,
|
|
"takeProfit": take_profit,
|
|
"reason": action["reason"] or "model_update",
|
|
"label": action_label("update", position["symbol"]),
|
|
}
|
|
)
|
|
return book, actions, warnings
|
|
|
|
|
|
def apply_account_circuit_breaker(
|
|
book: dict[str, Any],
|
|
settings: dict[str, Any],
|
|
decision_id: str,
|
|
*,
|
|
live_mode: bool,
|
|
live_status_payload: dict[str, Any] | None = None,
|
|
live_config: dict[str, Any] | None = None,
|
|
) -> tuple[dict[str, Any], list[dict[str, Any]], list[str]]:
|
|
account = summarize_account(book, settings)
|
|
if account["drawdownPct"] < settings["maxAccountDrawdownPct"]:
|
|
book["circuitBreakerTripped"] = False
|
|
book["circuitBreakerReason"] = None
|
|
return book, [], []
|
|
book["circuitBreakerTripped"] = True
|
|
book["circuitBreakerReason"] = f"Drawdown {account['drawdownPct']:.2f}% breached max {settings['maxAccountDrawdownPct']:.2f}%."
|
|
actions: list[dict[str, Any]] = []
|
|
warnings: list[str] = []
|
|
for position in list(book.get("openPositions", [])):
|
|
if live_mode:
|
|
if not live_status_payload or not live_status_payload.get("canExecute"):
|
|
warnings.append(f"Circuit breaker could not close live {position['symbol']} because real execution is not enabled.")
|
|
continue
|
|
cancel_all_open_orders(live_config, position["symbol"])
|
|
order_side = "SELL" if position["side"] == "long" else "BUY"
|
|
place_market_order(live_config, symbol=position["symbol"], side=order_side, quantity=position["quantity"], reduce_only=True)
|
|
book, recorded = close_position(
|
|
book,
|
|
position,
|
|
num(position.get("lastMarkPrice")) or num(position.get("entryPrice")) or 0,
|
|
decision_id,
|
|
"circuit_breaker",
|
|
)
|
|
recorded["type"] = "circuit_breaker"
|
|
recorded["label"] = action_label("circuit_breaker")
|
|
actions.append(recorded)
|
|
return book, actions, warnings
|
|
|
|
|
|
def _fetch_live_contexts(symbols: list[str], prompt_kline_feeds: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], list[str]]:
|
|
return _fetch_live_contexts_for_exchange(symbols, prompt_kline_feeds)
|
|
|
|
|
|
def _fetch_live_contexts_for_exchange(
|
|
symbols: list[str],
|
|
prompt_kline_feeds: dict[str, Any],
|
|
exchange_id: str | None = None,
|
|
) -> tuple[dict[str, dict[str, Any]], list[str]]:
|
|
live_by_symbol: dict[str, dict[str, Any]] = {}
|
|
warnings: list[str] = []
|
|
with ThreadPoolExecutor(max_workers=min(4, max(1, len(symbols)))) as executor:
|
|
futures = {
|
|
executor.submit(fetch_candidate_live_context, symbol, prompt_kline_feeds, exchange_id): symbol
|
|
for symbol in symbols
|
|
}
|
|
for future in as_completed(futures):
|
|
symbol = futures[future]
|
|
try:
|
|
live_by_symbol[symbol] = future.result()
|
|
except Exception as error:
|
|
warnings.append(f"{symbol}: {error}")
|
|
return live_by_symbol, warnings
|
|
|
|
|
|
def run_trading_cycle(reason: str = "manual", mode_override: str | None = None) -> dict[str, Any]:
|
|
settings = read_trading_settings()
|
|
settings["mode"] = clean_mode(mode_override or settings["mode"])
|
|
universe = read_fixed_universe()
|
|
account_key = account_key_for_mode(settings["mode"])
|
|
cycle_exchange_id = str(settings.get("activeExchange") or "binance").strip().lower() or "binance"
|
|
live_config = None
|
|
if account_key == "live":
|
|
live_config = read_live_trading_config()
|
|
cycle_exchange_id = str(live_config.get("exchange") or cycle_exchange_id).strip().lower() or cycle_exchange_id
|
|
scan = read_latest_scan(cycle_exchange_id)
|
|
if universe.get("dynamicSource", {}).get("enabled") or not scan["opportunities"] or str(scan.get("exchange") or "").strip().lower() != cycle_exchange_id:
|
|
scan = refresh_candidate_pool(cycle_exchange_id)
|
|
state = read_trading_state(settings)
|
|
book = state[account_key]
|
|
if account_key != "live":
|
|
book["initialCapitalUsd"] = settings["initialCapitalUsd"]
|
|
book.setdefault("sessionStartedAt", book.get("sessionStartedAt") or now_iso())
|
|
decision_id = f"trade-cycle-{int(__import__('time').time() * 1000)}"
|
|
warnings: list[str] = []
|
|
live_status_payload = None
|
|
if account_key == "live":
|
|
book, live_warnings, live_status_payload, live_config = sync_live_book(book, settings)
|
|
warnings.extend(live_warnings)
|
|
state["live"] = book
|
|
prompt_settings = read_prompt_settings()
|
|
prompt_kline_feeds = prompt_settings.get("klineFeeds") if isinstance(prompt_settings.get("klineFeeds"), dict) else {}
|
|
raw_candidates = candidate_universe_from_scan(scan)
|
|
symbols = []
|
|
for item in raw_candidates:
|
|
symbol = str(item.get("symbol") or "").upper()
|
|
if symbol and symbol not in symbols:
|
|
symbols.append(symbol)
|
|
for position in book.get("openPositions", []):
|
|
if position["symbol"] not in symbols:
|
|
symbols.append(position["symbol"])
|
|
live_by_symbol, live_context_warnings = _fetch_live_contexts_for_exchange(symbols, prompt_kline_feeds, cycle_exchange_id)
|
|
warnings.extend(live_context_warnings)
|
|
mark_to_market(book, live_by_symbol)
|
|
protection_actions = apply_protection_hits(book, decision_id)
|
|
market_backdrop = fetch_market_backdrop(prompt_kline_feeds, cycle_exchange_id)
|
|
candidate_snapshots = []
|
|
for opportunity in raw_candidates:
|
|
symbol = str(opportunity.get("symbol") or "").upper()
|
|
live = live_by_symbol.get(symbol)
|
|
if not live:
|
|
continue
|
|
candidate_snapshots.append(build_candidate_snapshot(opportunity, live, settings, cycle_exchange_id))
|
|
candidates_by_symbol = {item["symbol"]: item for item in candidate_snapshots}
|
|
account_before = summarize_account(book, settings)
|
|
provider = read_llm_provider()
|
|
prompt = build_prompt(
|
|
settings=settings,
|
|
prompt_settings=prompt_settings,
|
|
provider=provider,
|
|
market_backdrop=market_backdrop,
|
|
account_summary=account_before,
|
|
open_positions=account_before["openPositions"],
|
|
open_orders=[normalize_order(item) for item in book.get("openOrders", [])],
|
|
candidates=candidate_snapshots,
|
|
)
|
|
model_result: dict[str, Any] | None = None
|
|
try:
|
|
model_result = generate_trading_decision(prompt, provider)
|
|
parsed_model = normalize_model_decision(
|
|
model_result["parsed"],
|
|
open_positions=account_before["openPositions"],
|
|
candidates_by_symbol=candidates_by_symbol,
|
|
)
|
|
except Exception as error:
|
|
warnings.append(f"Model decision failed: {error}")
|
|
parsed_model = default_model_decision(account_before["openPositions"])
|
|
management_actions = list(protection_actions)
|
|
for instruction in parsed_model["position_actions"]:
|
|
position = next((item for item in list(book.get("openPositions", [])) if item["symbol"] == instruction["symbol"]), None)
|
|
if not position:
|
|
continue
|
|
if account_key == "live":
|
|
book, applied_actions, applied_warnings = apply_live_position_action(
|
|
book,
|
|
position,
|
|
instruction,
|
|
decision_id,
|
|
live_status_payload or {"canExecute": False},
|
|
live_config or read_live_trading_config(),
|
|
settings,
|
|
)
|
|
else:
|
|
book, applied_actions, applied_warnings = apply_paper_position_action(
|
|
book,
|
|
position,
|
|
instruction,
|
|
decision_id,
|
|
)
|
|
management_actions.extend(applied_actions)
|
|
warnings.extend(applied_warnings)
|
|
book, breaker_actions, breaker_warnings = apply_account_circuit_breaker(
|
|
book,
|
|
settings,
|
|
decision_id,
|
|
live_mode=account_key == "live",
|
|
live_status_payload=live_status_payload,
|
|
live_config=live_config,
|
|
)
|
|
warnings.extend(breaker_warnings)
|
|
entry_actions: list[dict[str, Any]] = []
|
|
if not book.get("circuitBreakerTripped"):
|
|
account_after_management = summarize_account(book, settings)
|
|
open_symbols = {item["symbol"] for item in book.get("openPositions", [])}
|
|
opened = 0
|
|
for entry in parsed_model["entry_actions"]:
|
|
if opened >= settings["maxNewPositionsPerCycle"]:
|
|
break
|
|
if entry["symbol"] in open_symbols:
|
|
continue
|
|
if entry["confidence"] < settings["minConfidence"]:
|
|
continue
|
|
candidate = candidates_by_symbol.get(entry["symbol"])
|
|
if not candidate:
|
|
continue
|
|
side = entry["side"]
|
|
if side == "short" and not settings["allowShorts"]:
|
|
continue
|
|
entry_price = num(candidate.get("price")) or 0
|
|
stop_loss = num(entry.get("stopLoss"))
|
|
take_profit = num(entry.get("takeProfit"))
|
|
if entry_price <= 0 or stop_loss is None:
|
|
continue
|
|
if not _risk_valid_for_side(side, entry_price, stop_loss, take_profit):
|
|
warnings.append(f"Ignored invalid entry risk for {entry['symbol']}.")
|
|
continue
|
|
notional_usd = position_notional_from_risk(
|
|
account_after_management,
|
|
entry_price=entry_price,
|
|
stop_loss=stop_loss,
|
|
settings=settings,
|
|
)
|
|
if notional_usd < 20:
|
|
continue
|
|
if account_key == "live":
|
|
live_config = live_config or read_live_trading_config()
|
|
live_status_payload = live_status_payload or live_execution_status(live_config, settings)
|
|
if not live_status_payload["canExecute"]:
|
|
warnings.append(f"Skipped live entry {entry['symbol']}: real execution is not enabled.")
|
|
continue
|
|
notional_usd = cap_live_notional_by_margin(
|
|
notional_usd,
|
|
account_summary=account_after_management,
|
|
live_config=live_config,
|
|
)
|
|
if notional_usd < 20:
|
|
warnings.append(f"Skipped live entry {entry['symbol']}: available margin is too small after leverage cap.")
|
|
continue
|
|
try:
|
|
apply_symbol_settings(live_config, entry["symbol"])
|
|
except Exception as error:
|
|
warnings.append(f"Live symbol settings update skipped for {entry['symbol']}: {error}")
|
|
quantity = normalize_quantity(live_config, entry["symbol"], notional_usd=notional_usd, reference_price=entry_price)
|
|
order_side = "BUY" if side == "long" else "SELL"
|
|
place_market_order(live_config, symbol=entry["symbol"], side=order_side, quantity=quantity)
|
|
if settings["liveExecution"]["useExchangeProtectionOrders"]:
|
|
try:
|
|
cancel_all_open_orders(live_config, entry["symbol"])
|
|
place_protection_orders(
|
|
live_config,
|
|
symbol=entry["symbol"],
|
|
position_side=side,
|
|
stop_loss=stop_loss,
|
|
take_profit=take_profit,
|
|
)
|
|
except Exception as error:
|
|
warnings.append(f"Exchange protection order placement failed for {entry['symbol']}: {error}")
|
|
else:
|
|
open_paper_position(
|
|
book,
|
|
candidate=candidate,
|
|
side=side,
|
|
stop_loss=stop_loss,
|
|
take_profit=take_profit,
|
|
confidence=entry["confidence"],
|
|
notional_usd=notional_usd,
|
|
reason=entry["reason"],
|
|
decision_id=decision_id,
|
|
)
|
|
open_symbols.add(entry["symbol"])
|
|
opened += 1
|
|
entry_actions.append(
|
|
{
|
|
"type": "open",
|
|
"symbol": entry["symbol"],
|
|
"side": side,
|
|
"confidence": entry["confidence"],
|
|
"notionalUsd": notional_usd,
|
|
"stopLoss": stop_loss,
|
|
"takeProfit": take_profit,
|
|
"reason": entry["reason"],
|
|
"label": action_label("open", entry["symbol"], side),
|
|
}
|
|
)
|
|
account_after_management = summarize_account(book, settings)
|
|
if account_key == "live":
|
|
book, live_warnings, live_status_payload, live_config = sync_live_book(book, settings)
|
|
warnings.extend(live_warnings)
|
|
state["live"] = book
|
|
else:
|
|
state["paper"] = book
|
|
account_after = summarize_account(book, settings)
|
|
if account_after["equityUsd"] > (num(book.get("highWatermarkEquity")) or book["initialCapitalUsd"]):
|
|
book["highWatermarkEquity"] = account_after["equityUsd"]
|
|
book["lastDecisionAt"] = now_iso()
|
|
decision = normalize_decision(
|
|
{
|
|
"id": decision_id,
|
|
"startedAt": now_iso(),
|
|
"finishedAt": now_iso(),
|
|
"runnerReason": reason,
|
|
"mode": settings["mode"],
|
|
"prompt": prompt,
|
|
"promptSummary": one_line(parsed_model.get("summary") or f"Managed {len(account_before['openPositions'])} positions and reviewed {len(candidate_snapshots)} candidates."),
|
|
"output": {
|
|
"summary": parsed_model.get("summary"),
|
|
"positionActions": parsed_model["position_actions"],
|
|
"entryActions": parsed_model["entry_actions"],
|
|
"watchlist": parsed_model["watchlist"],
|
|
"providerStatus": provider_status(provider),
|
|
"liveExecutionStatus": live_status_payload,
|
|
},
|
|
"rawModelResponse": model_result or {},
|
|
"actions": management_actions + breaker_actions + entry_actions,
|
|
"warnings": warnings,
|
|
"candidateUniverse": [serialize_candidate_for_history(item) for item in candidate_snapshots],
|
|
"accountBefore": account_before,
|
|
"accountAfter": account_after,
|
|
}
|
|
)
|
|
book.setdefault("decisions", []).append(decision)
|
|
state["adaptive"] = {
|
|
"updatedAt": now_iso(),
|
|
"notes": [
|
|
f"Latest cycle used {provider['preset']} / {provider['model']} in {settings['mode']} mode.",
|
|
f"Current account drawdown is {account_after['drawdownPct']:.2f}% and gross exposure is ${account_after['grossExposureUsd']:.2f}.",
|
|
"The editable trade-logic fields affect judgment only. Market data, positions, and risk limits are always injected by the system.",
|
|
],
|
|
}
|
|
write_trading_state(state)
|
|
archive_decision(decision)
|
|
return {
|
|
"settings": settings,
|
|
"state": state,
|
|
"decision": decision,
|
|
"marketBackdrop": market_backdrop,
|
|
"liveExecutionStatus": live_status_payload,
|
|
}
|
|
|
|
|
|
def run_trading_cycle_batch(reason: str = "manual", modes: list[str] | None = None) -> dict[str, Any]:
|
|
settings = read_trading_settings()
|
|
requested_modes = [clean_mode(item) for item in (modes or enabled_modes(settings))]
|
|
unique_modes: list[str] = []
|
|
for mode in requested_modes:
|
|
if mode not in unique_modes:
|
|
unique_modes.append(mode)
|
|
results = []
|
|
for mode in unique_modes:
|
|
result = run_trading_cycle(reason=reason, mode_override=mode)
|
|
results.append(
|
|
{
|
|
"ok": True,
|
|
"mode": mode,
|
|
"result": result,
|
|
}
|
|
)
|
|
return {
|
|
"settings": settings,
|
|
"modes": unique_modes,
|
|
"activeMode": unique_modes[0] if unique_modes else "paper",
|
|
"results": results,
|
|
"primaryResult": results[0]["result"] if results else None,
|
|
}
|
|
|
|
|
|
def preview_trading_prompt_decision(mode_override: str | None = None, prompt_override: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
settings = read_trading_settings()
|
|
settings["mode"] = clean_mode(mode_override or settings["mode"])
|
|
universe = read_fixed_universe()
|
|
account_key = account_key_for_mode(settings["mode"])
|
|
cycle_exchange_id = str(settings.get("activeExchange") or "binance").strip().lower() or "binance"
|
|
if account_key == "live":
|
|
live_config = read_live_trading_config()
|
|
cycle_exchange_id = str(live_config.get("exchange") or cycle_exchange_id).strip().lower() or cycle_exchange_id
|
|
scan = read_latest_scan(cycle_exchange_id)
|
|
if universe.get("dynamicSource", {}).get("enabled") or not scan["opportunities"] or str(scan.get("exchange") or "").strip().lower() != cycle_exchange_id:
|
|
scan = refresh_candidate_pool(cycle_exchange_id)
|
|
state = read_trading_state(settings)
|
|
book = deepcopy(state[account_key])
|
|
warnings: list[str] = []
|
|
if account_key == "live":
|
|
book, live_warnings, _, _ = sync_live_book(book, settings)
|
|
warnings.extend(live_warnings)
|
|
prompt_settings = prompt_override or read_prompt_settings()
|
|
prompt_kline_feeds = prompt_settings.get("klineFeeds") if isinstance(prompt_settings.get("klineFeeds"), dict) else {}
|
|
raw_candidates = candidate_universe_from_scan(scan)
|
|
symbols = []
|
|
for item in raw_candidates:
|
|
symbol = str(item.get("symbol") or "").upper()
|
|
if symbol and symbol not in symbols:
|
|
symbols.append(symbol)
|
|
for position in book.get("openPositions", []):
|
|
if position["symbol"] not in symbols:
|
|
symbols.append(position["symbol"])
|
|
live_by_symbol, live_context_warnings = _fetch_live_contexts_for_exchange(symbols, prompt_kline_feeds, cycle_exchange_id)
|
|
warnings.extend(live_context_warnings)
|
|
mark_to_market(book, live_by_symbol)
|
|
market_backdrop = fetch_market_backdrop(prompt_kline_feeds, cycle_exchange_id)
|
|
candidate_snapshots = []
|
|
for opportunity in raw_candidates:
|
|
symbol = str(opportunity.get("symbol") or "").upper()
|
|
live = live_by_symbol.get(symbol)
|
|
if not live:
|
|
continue
|
|
candidate_snapshots.append(build_candidate_snapshot(opportunity, live, settings, cycle_exchange_id))
|
|
candidates_by_symbol = {item["symbol"]: item for item in candidate_snapshots}
|
|
account_summary = summarize_account(book, settings)
|
|
provider = read_llm_provider()
|
|
prompt = build_prompt(
|
|
settings=settings,
|
|
prompt_settings=prompt_settings,
|
|
provider=provider,
|
|
market_backdrop=market_backdrop,
|
|
account_summary=account_summary,
|
|
open_positions=account_summary["openPositions"],
|
|
open_orders=[normalize_order(item) for item in book.get("openOrders", [])],
|
|
candidates=candidate_snapshots,
|
|
)
|
|
model_result = generate_trading_decision(prompt, provider)
|
|
parsed_model = normalize_model_decision(
|
|
model_result["parsed"],
|
|
open_positions=account_summary["openPositions"],
|
|
candidates_by_symbol=candidates_by_symbol,
|
|
)
|
|
return {
|
|
"mode": settings["mode"],
|
|
"promptName": prompt_settings.get("name") or "default_trading_logic",
|
|
"candidateCount": len(candidate_snapshots),
|
|
"account": account_summary,
|
|
"warnings": warnings,
|
|
"prompt": prompt,
|
|
"rawText": model_result["rawText"],
|
|
"parsed": parsed_model,
|
|
"provider": model_result["provider"],
|
|
}
|
|
|
|
|
|
def summarize_book_history(book: dict[str, Any]) -> dict[str, Any]:
|
|
recent_decisions = list(book.get("decisions", []))[-8:]
|
|
decision_timeline = [
|
|
{
|
|
"id": item["id"],
|
|
"startedAt": item["startedAt"],
|
|
"finishedAt": item["finishedAt"],
|
|
"actions": item["actions"],
|
|
}
|
|
for item in book.get("decisions", [])[-240:]
|
|
]
|
|
return {
|
|
"sessionStartedAt": book.get("sessionStartedAt"),
|
|
"lastDecisionAt": book.get("lastDecisionAt"),
|
|
"decisions": recent_decisions,
|
|
"decisionTimeline": decision_timeline,
|
|
"exchangeClosedTrades": list(book.get("exchangeClosedTrades", [])),
|
|
"closedTrades": list(book.get("closedTrades", [])),
|
|
}
|
|
|
|
|
|
def compact_latest_decision(decision: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
if not decision:
|
|
return None
|
|
return {
|
|
"id": decision["id"],
|
|
"startedAt": decision["startedAt"],
|
|
"finishedAt": decision["finishedAt"],
|
|
"runnerReason": decision["runnerReason"],
|
|
"mode": decision["mode"],
|
|
"promptSummary": decision["promptSummary"],
|
|
"actionsCount": len(decision.get("actions", [])),
|
|
}
|
|
|
|
|
|
def summarize_trading_state() -> dict[str, Any]:
|
|
settings = read_trading_settings()
|
|
state = read_trading_state(settings)
|
|
live_status_payload = live_execution_status(read_live_trading_config(), settings)
|
|
scan = read_latest_scan(settings.get("activeExchange"))
|
|
active_mode = settings["mode"]
|
|
active_key = account_key_for_mode(active_mode)
|
|
active_book = state[active_key]
|
|
paper_account = summarize_account(state["paper"], {**settings, "mode": "paper"})
|
|
live_account = summarize_account(state["live"], {**settings, "mode": "live"})
|
|
active_account = summarize_account(active_book, settings)
|
|
return {
|
|
"settings": settings,
|
|
"activeMode": active_mode,
|
|
"paperTradingEnabled": settings.get("paperTrading", {}).get("enabled") is True,
|
|
"liveTradingEnabled": settings.get("liveTrading", {}).get("enabled") is True,
|
|
"scan": {
|
|
"runDate": scan.get("runDate"),
|
|
"fetchedAt": scan.get("fetchedAt"),
|
|
"candidateUniverseSize": len(scan.get("opportunities", [])),
|
|
},
|
|
"account": active_account,
|
|
"paperAccount": paper_account,
|
|
"liveAccount": live_account,
|
|
"adaptive": state.get("adaptive"),
|
|
"latestDecision": compact_latest_decision(safe_last(active_book.get("decisions", []))),
|
|
"latestPaperDecision": compact_latest_decision(safe_last(state["paper"].get("decisions", []))),
|
|
"latestLiveDecision": compact_latest_decision(safe_last(state["live"].get("decisions", []))),
|
|
"paperBook": state["paper"],
|
|
"liveBook": state["live"],
|
|
"activeBook": active_book,
|
|
"paperHistory": summarize_book_history(state["paper"]),
|
|
"liveHistory": summarize_book_history(state["live"]),
|
|
"liveExecutionStatus": live_status_payload,
|
|
"providerStatus": provider_status(),
|
|
}
|
|
|
|
|
|
def flatten_active_account(reason: str = "manual_flatten", mode_override: str | None = None) -> dict[str, Any]:
|
|
settings = read_trading_settings()
|
|
target_mode = clean_mode(mode_override or settings["mode"])
|
|
state = read_trading_state(settings)
|
|
account_key = account_key_for_mode(target_mode)
|
|
book = state[account_key]
|
|
decision_id = f"flatten-{int(__import__('time').time() * 1000)}"
|
|
actions = []
|
|
warnings: list[str] = []
|
|
if account_key == "live":
|
|
book, live_warnings, live_status_payload, live_config = sync_live_book(book, settings)
|
|
warnings.extend(live_warnings)
|
|
if not live_status_payload["canExecute"]:
|
|
raise RuntimeError("Live flatten requires real execution to be enabled.")
|
|
for position in list(book.get("openPositions", [])):
|
|
cancel_all_open_orders(live_config, position["symbol"])
|
|
side = "SELL" if position["side"] == "long" else "BUY"
|
|
place_market_order(live_config, symbol=position["symbol"], side=side, quantity=position["quantity"], reduce_only=True)
|
|
book, action = close_position(book, position, num(position.get("lastMarkPrice")) or num(position.get("entryPrice")) or 0, decision_id, reason)
|
|
actions.append(action)
|
|
book, live_warnings, _, _ = sync_live_book(book, settings)
|
|
warnings.extend(live_warnings)
|
|
else:
|
|
for position in list(book.get("openPositions", [])):
|
|
book, action = close_position(book, position, num(position.get("lastMarkPrice")) or num(position.get("entryPrice")) or 0, decision_id, reason)
|
|
actions.append(action)
|
|
decision = normalize_decision(
|
|
{
|
|
"id": decision_id,
|
|
"startedAt": now_iso(),
|
|
"finishedAt": now_iso(),
|
|
"runnerReason": "manual",
|
|
"mode": target_mode,
|
|
"prompt": f"Flatten all open {target_mode} positions because: {reason}",
|
|
"promptSummary": f"Flattened {len(actions)} open {target_mode} positions.",
|
|
"actions": actions,
|
|
"warnings": warnings,
|
|
"output": {"actions": actions},
|
|
"candidateUniverse": [],
|
|
"accountBefore": {},
|
|
"accountAfter": summarize_account(book, {**settings, "mode": target_mode}),
|
|
}
|
|
)
|
|
book.setdefault("decisions", []).append(decision)
|
|
book["lastDecisionAt"] = now_iso()
|
|
write_trading_state(state)
|
|
archive_decision(decision)
|
|
return state
|
|
|
|
|
|
def reset_paper_account(mode: str = "full") -> dict[str, Any]:
|
|
settings = read_trading_settings()
|
|
state = read_trading_state(settings)
|
|
if str(mode) == "equity_only":
|
|
state["paper"]["initialCapitalUsd"] = settings["initialCapitalUsd"]
|
|
state["paper"]["highWatermarkEquity"] = settings["initialCapitalUsd"]
|
|
state["paper"]["openPositions"] = []
|
|
state["paper"]["circuitBreakerTripped"] = False
|
|
state["paper"]["circuitBreakerReason"] = None
|
|
else:
|
|
state["paper"] = empty_trading_account(settings["initialCapitalUsd"], "paper")
|
|
state["adaptive"] = {
|
|
"updatedAt": now_iso(),
|
|
"notes": [
|
|
"Paper account was reset in the Python build.",
|
|
"The trade-logic fields, provider config, and proxy config were preserved.",
|
|
],
|
|
}
|
|
return write_trading_state(state)
|
|
|
|
|
|
def reset_trading_account(mode: str = "paper") -> dict[str, Any]:
|
|
reset_mode = str(mode or "paper").strip().lower()
|
|
if reset_mode in {"paper", "full", "equity_only"}:
|
|
return reset_paper_account(reset_mode if reset_mode == "equity_only" else "full")
|
|
|
|
if reset_mode != "live":
|
|
raise ValueError(f"Unsupported reset mode: {mode}")
|
|
|
|
settings = read_trading_settings()
|
|
state = read_trading_state(settings)
|
|
book = state["live"]
|
|
book, warnings, live_status_payload, live_config = sync_live_book(book, settings)
|
|
state["live"] = book
|
|
if not live_status_payload["canSync"]:
|
|
state["live"] = empty_trading_account(settings["initialCapitalUsd"], "exchange")
|
|
state["adaptive"] = {
|
|
"updatedAt": now_iso(),
|
|
"notes": [
|
|
"Live account local state was reset without exchange sync.",
|
|
"No valid live API configuration was available, so only local live decisions, positions, and drawdown baseline were cleared.",
|
|
],
|
|
}
|
|
return write_trading_state(state)
|
|
if book.get("openPositions"):
|
|
if not live_status_payload["canExecute"]:
|
|
raise RuntimeError("实盘重置发现当前仍有持仓。请先启用实盘并关闭模拟下单,或先手动全部平仓。")
|
|
for position in list(book.get("openPositions", [])):
|
|
cancel_all_open_orders(live_config, position["symbol"])
|
|
side = "SELL" if position["side"] == "long" else "BUY"
|
|
place_market_order(
|
|
live_config,
|
|
symbol=position["symbol"],
|
|
side=side,
|
|
quantity=position["quantity"],
|
|
reduce_only=True,
|
|
)
|
|
fresh_book = empty_trading_account(num(book.get("exchangeEquityUsd")) or settings["initialCapitalUsd"], "exchange")
|
|
fresh_book, sync_warnings, _, _ = sync_live_book(fresh_book, settings)
|
|
warnings.extend(sync_warnings)
|
|
state["live"] = fresh_book
|
|
state["adaptive"] = {
|
|
"updatedAt": now_iso(),
|
|
"notes": [
|
|
"Live account was reset in the Python build.",
|
|
"Live decisions, local estimated realized PnL, drawdown baseline, and synced positions were cleared.",
|
|
] + ([f"Reset warnings: {'; '.join(warnings[:3])}"] if warnings else []),
|
|
}
|
|
return write_trading_state(state)
|