decision_engine.py 53 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172
  1. from __future__ import annotations
  2. """Deterministic strategy-supervision logic for Hermes.
  3. This is the first decision slice. Hermes is currently acting as a supervisor for
  4. existing trader strategies, not as a direct trading engine.
  5. Design intent:
  6. - prefer one active posture at a time over layered companions
  7. - detect when grid trading becomes unsafe because market posture or wallet
  8. balance no longer supports it
  9. - switch cleanly between directional, range, and rebalancing phases
  10. """
  11. import json
  12. from dataclasses import dataclass
  13. from datetime import datetime, timezone
  14. from typing import Any
  15. @dataclass(frozen=True)
  16. class DecisionSnapshot:
  17. mode: str
  18. action: str
  19. target_strategy: str | None
  20. reason_summary: str
  21. confidence: float
  22. requires_action: bool
  23. payload: dict[str, Any]
  24. def _clamp(value: float, lower: float, upper: float) -> float:
  25. return max(lower, min(upper, value))
  26. def _safe_float(value: Any) -> float | None:
  27. try:
  28. if value is None:
  29. return None
  30. return float(value)
  31. except Exception:
  32. return None
  33. def _inventory_state_label(value: Any) -> str:
  34. state = str(value or "unknown").strip().lower()
  35. aliases = {
  36. "critical": "critically_unbalanced",
  37. "critically_imbalanced": "critically_unbalanced",
  38. "depleted_base": "depleted_base_side",
  39. "depleted_quote": "depleted_quote_side",
  40. "one_sided_base": "depleted_base_side",
  41. "one_sided_quote": "depleted_quote_side",
  42. }
  43. return aliases.get(state, state)
  44. SEVERE_INVENTORY_STATES = {"critically_unbalanced", "depleted_base_side", "depleted_quote_side"}
  45. REBALANCE_INVENTORY_STATES = {"base_heavy", "quote_heavy", *SEVERE_INVENTORY_STATES}
  46. def _infer_market_pair(concern: dict[str, Any]) -> tuple[str, str]:
  47. base = str(concern.get("base_currency") or "").strip().upper()
  48. quote = str(concern.get("quote_currency") or "").strip().upper()
  49. if base and quote:
  50. return base, quote
  51. market = str(concern.get("market_symbol") or "").strip().upper().replace("/", "").replace("-", "")
  52. for suffix in ("USDT", "USDC", "USD", "EUR", "BTC", "ETH"):
  53. if market.endswith(suffix) and len(market) > len(suffix):
  54. inferred_base = market[:-len(suffix)]
  55. inferred_quote = suffix
  56. return base or inferred_base, quote or inferred_quote
  57. return base or market, quote or "USD"
  58. def assess_wallet_state(*, account_info: dict[str, Any], concern: dict[str, Any], price: float | None, strategies: list[dict[str, Any]] | None = None) -> dict[str, Any]:
  59. """Summarize inventory health for strategy switching.
  60. The key output is whether the wallet is balanced enough for range/grid
  61. harvesting, or so skewed that Hermes should prefer trend capture or
  62. rebalancing before grid is allowed again.
  63. """
  64. balances = account_info.get("balances") if isinstance(account_info.get("balances"), list) else []
  65. base, quote = _infer_market_pair(concern)
  66. base_available = 0.0
  67. quote_available = 0.0
  68. for item in balances:
  69. if not isinstance(item, dict):
  70. continue
  71. asset = str(item.get("asset_code") or item.get("asset") or "").upper()
  72. amount = _safe_float(item.get("available") if item.get("available") is not None else item.get("total"))
  73. if amount is None:
  74. continue
  75. if asset == base:
  76. base_available = amount
  77. elif asset == quote:
  78. quote_available = amount
  79. reserved_base = 0.0
  80. reserved_quote = 0.0
  81. for strategy in strategies or []:
  82. if not isinstance(strategy, dict):
  83. continue
  84. if str(strategy.get("account_id") or "").strip() != str(concern.get("account_id") or "").strip():
  85. continue
  86. market_symbol = str(strategy.get("market_symbol") or "").strip().lower()
  87. if market_symbol and market_symbol != str(concern.get("market_symbol") or "").strip().lower():
  88. continue
  89. if str(strategy.get("mode") or "off") == "off":
  90. continue
  91. state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
  92. orders = state.get("orders") if isinstance(state.get("orders"), list) else []
  93. for order in orders:
  94. if not isinstance(order, dict):
  95. continue
  96. if str(order.get("status") or "open").lower() not in {"open", "live", "active"}:
  97. continue
  98. side = str(order.get("side") or "").lower()
  99. amount = _safe_float(order.get("amount") or order.get("amount_remaining")) or 0.0
  100. order_price = _safe_float(order.get("price")) or price or 0.0
  101. if side == "sell":
  102. reserved_base += amount
  103. elif side == "buy":
  104. reserved_quote += amount * order_price
  105. price = price or 0.0
  106. effective_base = base_available + reserved_base
  107. effective_quote = quote_available + reserved_quote
  108. base_value = effective_base * price if price > 0 else 0.0
  109. quote_value = effective_quote
  110. total_value = base_value + quote_value
  111. base_ratio = (base_value / total_value) if total_value > 0 else 0.5
  112. quote_ratio = (quote_value / total_value) if total_value > 0 else 0.5
  113. imbalance = abs(base_ratio - 0.5)
  114. if total_value <= 0:
  115. inventory_state = "unknown"
  116. elif base_ratio <= 0.02:
  117. inventory_state = "depleted_base_side"
  118. elif quote_ratio <= 0.02:
  119. inventory_state = "depleted_quote_side"
  120. elif base_ratio < 0.08:
  121. inventory_state = "critically_unbalanced"
  122. elif quote_ratio < 0.08:
  123. inventory_state = "critically_unbalanced"
  124. elif imbalance >= 0.35:
  125. inventory_state = "critically_unbalanced"
  126. elif base_ratio > 0.62:
  127. inventory_state = "base_heavy"
  128. elif quote_ratio > 0.62:
  129. inventory_state = "quote_heavy"
  130. else:
  131. inventory_state = "balanced"
  132. grid_ready = inventory_state == "balanced"
  133. rebalance_needed = inventory_state in REBALANCE_INVENTORY_STATES
  134. return {
  135. "generated_at": datetime.now(timezone.utc).isoformat(),
  136. "base_currency": base,
  137. "quote_currency": quote,
  138. "base_available": round(base_available, 8),
  139. "quote_available": round(quote_available, 8),
  140. "base_reserved": round(reserved_base, 8),
  141. "quote_reserved": round(reserved_quote, 8),
  142. "base_effective": round(effective_base, 8),
  143. "quote_effective": round(effective_quote, 8),
  144. "base_value": round(base_value, 4),
  145. "quote_value": round(quote_value, 4),
  146. "total_value": round(total_value, 4),
  147. "base_ratio": round(base_ratio, 4),
  148. "quote_ratio": round(quote_ratio, 4),
  149. "imbalance_score": round(imbalance, 4),
  150. "inventory_state": inventory_state,
  151. "grid_ready": grid_ready,
  152. "rebalance_needed": rebalance_needed,
  153. }
  154. def normalize_strategy_snapshot(strategy: dict[str, Any]) -> dict[str, Any]:
  155. strategy_type = str(strategy.get("strategy_type") or "unknown")
  156. mode = str(strategy.get("mode") or "off")
  157. state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
  158. config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
  159. report = strategy.get("report") if isinstance(strategy.get("report"), dict) else {}
  160. report_fit = report.get("fit") if isinstance(report.get("fit"), dict) else {}
  161. report_supervision = report.get("supervision") if isinstance(report.get("supervision"), dict) else {}
  162. report_state = report.get("state") if isinstance(report.get("state"), dict) else {}
  163. # Stable minimum contract used by Hermes while the trader-side strategy
  164. # metadata evolves. These values can later be sourced directly from richer
  165. # reports, but the decision layer keeps a normalized shape from day one.
  166. defaults = {
  167. "grid_trader": {
  168. "role": "primary",
  169. "inventory_behavior": "balanced",
  170. "requires_rebalance_before_start": False,
  171. "requires_rebalance_before_stop": False,
  172. "safe_when_unbalanced": False,
  173. "can_run_with": ["exposure_protector"],
  174. },
  175. "trend_follower": {
  176. "role": "primary",
  177. "inventory_behavior": "accumulative_long",
  178. "requires_rebalance_before_start": False,
  179. "requires_rebalance_before_stop": False,
  180. "safe_when_unbalanced": True,
  181. "can_run_with": [],
  182. "trade_side": "both",
  183. },
  184. "exposure_protector": {
  185. "role": "rebalancing",
  186. "inventory_behavior": "rebalancing",
  187. "requires_rebalance_before_start": False,
  188. "requires_rebalance_before_stop": False,
  189. "safe_when_unbalanced": True,
  190. "can_run_with": [],
  191. },
  192. }
  193. contract = defaults.get(strategy_type, {
  194. "role": "primary",
  195. "inventory_behavior": "unknown",
  196. "requires_rebalance_before_start": False,
  197. "requires_rebalance_before_stop": False,
  198. "safe_when_unbalanced": True,
  199. "can_run_with": [],
  200. })
  201. contract = {**contract, **report_fit}
  202. return {
  203. "id": strategy.get("id"),
  204. "strategy_type": strategy_type,
  205. "mode": mode,
  206. "enabled": mode != "off",
  207. "status": strategy.get("status") or ("running" if mode != "off" else "stopped"),
  208. "market_symbol": strategy.get("market_symbol"),
  209. "account_id": strategy.get("account_id"),
  210. "open_order_count": int(state.get("open_order_count") or report_state.get("open_order_count") or strategy.get("open_order_count") or 0),
  211. "last_action": state.get("last_action") or report_state.get("last_action") or strategy.get("last_side"),
  212. "last_error": state.get("last_error") or report_state.get("last_error") or "",
  213. "contract": contract,
  214. "trade_side": str(config.get("trade_side") or contract.get("trade_side") or "both"),
  215. "supervision": report_supervision,
  216. "config": config,
  217. "state": {**report_state, **state},
  218. }
  219. def _argus_decision_context(narrative_payload: dict[str, Any]) -> dict[str, Any]:
  220. argus = narrative_payload.get("argus_context") if isinstance(narrative_payload.get("argus_context"), dict) else {}
  221. regime = str(argus.get("regime") or argus.get("snapshot_regime") or "").strip()
  222. confidence_raw = argus.get("regime_confidence") if argus.get("regime_confidence") is not None else argus.get("snapshot_confidence")
  223. confidence = float(confidence_raw) if isinstance(confidence_raw, (int, float)) else 0.0
  224. components = argus.get("regime_components") if isinstance(argus.get("regime_components"), dict) else {}
  225. if not components:
  226. components = argus.get("snapshot_components") if isinstance(argus.get("snapshot_components"), dict) else {}
  227. compression = float(components.get("compression") or 0.0)
  228. compression_active = regime == "compression" and confidence >= 0.55 and compression >= 0.65
  229. return {
  230. "regime": regime,
  231. "confidence": round(confidence, 4),
  232. "components": components,
  233. "compression": round(compression, 4),
  234. "compression_active": compression_active,
  235. }
  236. def _parse_timestamp(value: Any) -> datetime | None:
  237. text = str(value or "").strip()
  238. if not text:
  239. return None
  240. if text.endswith("Z"):
  241. text = text[:-1] + "+00:00"
  242. try:
  243. parsed = datetime.fromisoformat(text)
  244. except Exception:
  245. return None
  246. if parsed.tzinfo is None:
  247. return parsed.replace(tzinfo=timezone.utc)
  248. return parsed.astimezone(timezone.utc)
  249. def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], wallet_state: dict[str, Any]) -> dict[str, Any]:
  250. stance = str(narrative.get("stance") or "neutral_rotational")
  251. opportunity_map = narrative.get("opportunity_map") if isinstance(narrative.get("opportunity_map"), dict) else {}
  252. breakout_pressure = narrative.get("grid_breakout_pressure") if isinstance(narrative.get("grid_breakout_pressure"), dict) else {}
  253. breakout_phase = str(breakout_pressure.get("phase") or "none")
  254. continuation = float(opportunity_map.get("continuation") or 0.0)
  255. mean_reversion = float(opportunity_map.get("mean_reversion") or 0.0)
  256. reversal = float(opportunity_map.get("reversal") or 0.0)
  257. wait = float(opportunity_map.get("wait") or 0.0)
  258. inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
  259. argus_context = _argus_decision_context(narrative)
  260. strategy_type = strategy["strategy_type"]
  261. supervision = strategy.get("supervision") if isinstance(strategy.get("supervision"), dict) else {}
  262. inventory_pressure = str(supervision.get("inventory_pressure") or "")
  263. capacity_available = bool(supervision.get("capacity_available"))
  264. side_capacity = supervision.get("side_capacity") if isinstance(supervision.get("side_capacity"), dict) else {}
  265. score = 0.0
  266. reasons: list[str] = []
  267. blocks: list[str] = []
  268. if strategy_type == "grid_trader":
  269. score += mean_reversion * 1.8
  270. if stance in {"neutral_rotational", "breakout_watch"}:
  271. score += 0.45
  272. reasons.append("narrative still supports rotational structure")
  273. if continuation >= 0.45:
  274. score -= 0.8
  275. blocks.append("continuation pressure is too strong for safe grid harvesting")
  276. if inventory_state != "balanced":
  277. score -= 1.0
  278. blocks.append(f"wallet is not grid-ready: {inventory_state}")
  279. else:
  280. reasons.append("wallet is balanced enough for two-sided harvesting")
  281. if not capacity_available:
  282. score -= 0.25
  283. blocks.append("grid report shows one-sided capacity")
  284. if side_capacity and not (bool(side_capacity.get("buy", True)) and bool(side_capacity.get("sell", True))):
  285. score -= 0.25
  286. blocks.append("grid side capacity is asymmetric")
  287. if argus_context["compression_active"]:
  288. score += 0.2
  289. reasons.append("Argus compression supports staying selective with grid")
  290. elif strategy_type == "trend_follower":
  291. score += continuation * 1.9
  292. trade_side = _strategy_trade_side(strategy)
  293. narrative_direction = _narrative_direction(narrative)
  294. if stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
  295. score += 0.5
  296. reasons.append("narrative supports directional continuation")
  297. if trade_side == "buy":
  298. if narrative_direction == "bullish":
  299. score += 0.6
  300. reasons.append("buy-side trend instance matches bullish direction")
  301. elif narrative_direction == "bearish":
  302. score -= 0.9
  303. blocks.append("buy-side trend instance conflicts with bearish direction")
  304. elif trade_side == "sell":
  305. if narrative_direction == "bearish":
  306. score += 0.6
  307. reasons.append("sell-side trend instance matches bearish direction")
  308. elif narrative_direction == "bullish":
  309. score -= 0.9
  310. blocks.append("sell-side trend instance conflicts with bullish direction")
  311. if breakout_phase == "confirmed":
  312. score += 0.45
  313. reasons.append("confirmed breakout pressure supports directional continuation")
  314. elif breakout_phase == "developing":
  315. score += 0.2
  316. reasons.append("breakout pressure is developing in trend's favor")
  317. if wait >= 0.45 and breakout_phase != "confirmed":
  318. score -= 0.35
  319. blocks.append("market still has too much wait/uncertainty for trend commitment")
  320. if inventory_state in SEVERE_INVENTORY_STATES:
  321. score -= 0.25
  322. blocks.append("wallet may be too skewed for clean directional scaling")
  323. if inventory_pressure in {"base_heavy", "quote_heavy"}:
  324. score -= 0.1
  325. blocks.append("trend report shows rising inventory pressure")
  326. if not capacity_available:
  327. score -= 0.1
  328. blocks.append("trend strength is below its own capacity threshold")
  329. if trade_side == "both" and narrative_direction in {"bullish", "bearish"}:
  330. score += 0.15
  331. reasons.append("generic trend instance can follow either side")
  332. if argus_context["compression_active"] and breakout_phase != "confirmed":
  333. score -= 0.15
  334. blocks.append("Argus compression says the broader tape is still range-like")
  335. elif strategy_type == "exposure_protector":
  336. score += reversal * 0.4 + wait * 0.5
  337. if wallet_state.get("rebalance_needed"):
  338. score += 1.1
  339. reasons.append("wallet imbalance calls for rebalancing protection")
  340. if inventory_state in SEVERE_INVENTORY_STATES:
  341. score += 0.45
  342. reasons.append("inventory drift is high enough to justify defensive action")
  343. if stance in {"constructive_bullish", "constructive_bearish"} and continuation > 0.65:
  344. score -= 0.2
  345. if inventory_pressure in {"critical", "elevated"}:
  346. score += 0.25
  347. reasons.append("protector reports active inventory pressure")
  348. if strategy.get("last_error"):
  349. score -= 0.25
  350. blocks.append("strategy recently reported an error")
  351. if bool(supervision.get("degraded")):
  352. score -= 0.15
  353. blocks.append("strategy self-reports degraded supervision state")
  354. return {
  355. "strategy_id": strategy.get("id"),
  356. "strategy_type": strategy_type,
  357. "score": round(score, 4),
  358. "reasons": reasons,
  359. "blocks": blocks,
  360. "enabled": strategy.get("enabled", False),
  361. }
  362. def _breakout_phase_from_score(score: float) -> str:
  363. if score >= 3.45:
  364. return "confirmed"
  365. if score >= 2.45:
  366. return "developing"
  367. if score >= 1.4:
  368. return "probing"
  369. return "none"
  370. def _local_breakout_snapshot(narrative_payload: dict[str, Any]) -> dict[str, Any]:
  371. scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
  372. cross = narrative_payload.get("cross_scope_summary") if isinstance(narrative_payload.get("cross_scope_summary"), dict) else {}
  373. micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
  374. meso = scoped.get("meso") if isinstance(scoped.get("meso"), dict) else {}
  375. macro = scoped.get("macro") if isinstance(scoped.get("macro"), dict) else {}
  376. micro_impulse = str(micro.get("impulse") or "mixed")
  377. micro_bias = str(micro.get("trend_bias") or "mixed")
  378. meso_structure = str(meso.get("structure") or "rotation")
  379. meso_bias = str(meso.get("momentum_bias") or "neutral")
  380. macro_bias = str(macro.get("bias") or "mixed")
  381. alignment = str(cross.get("alignment") or "partial_alignment")
  382. friction = str(cross.get("friction") or "medium")
  383. micro_directional = micro_impulse in {"up", "down"} and micro_bias in {"bullish", "bearish"}
  384. meso_directional = meso_structure == "trend_continuation" and meso_bias in {"bullish", "bearish"}
  385. macro_supportive = macro_bias in {"bullish", "bearish"}
  386. score = 0.0
  387. if micro_directional:
  388. score += 1.0
  389. if meso_directional:
  390. score += 1.1
  391. if macro_supportive:
  392. score += 0.55
  393. if alignment == "micro_meso_macro_aligned":
  394. score += 0.8
  395. elif alignment == "partial_alignment":
  396. score += 0.35
  397. if friction == "low":
  398. score += 0.45
  399. elif friction == "medium":
  400. score += 0.15
  401. return {
  402. "score": round(score, 4),
  403. "phase": _breakout_phase_from_score(score),
  404. "micro_impulse": micro_impulse,
  405. "micro_bias": micro_bias,
  406. "meso_structure": meso_structure,
  407. "meso_bias": meso_bias,
  408. "macro_bias": macro_bias,
  409. "alignment": alignment,
  410. "friction": friction,
  411. }
  412. def _breakout_memory(narrative_payload: dict[str, Any], history_window: dict[str, Any] | None, current_breakout: dict[str, Any]) -> dict[str, Any]:
  413. recent_states = history_window.get("recent_states") if isinstance(history_window, dict) and isinstance(history_window.get("recent_states"), list) else []
  414. window_seconds = int(history_window.get("window_seconds") or 0) if isinstance(history_window, dict) else 0
  415. current_ts = _parse_timestamp(narrative_payload.get("generated_at")) or datetime.now(timezone.utc)
  416. current_direction = str(current_breakout.get("meso_bias") or "neutral")
  417. directional = current_direction in {"bullish", "bearish"} and current_breakout.get("meso_structure") == "trend_continuation"
  418. if not directional:
  419. return {"window_seconds": window_seconds, "samples_considered": 0, "qualifying_samples": 0, "same_direction_seconds": 0, "promoted_to_confirmed": False}
  420. qualifying_samples = 0
  421. oldest_match: datetime | None = None
  422. for row in recent_states:
  423. if not isinstance(row, dict):
  424. continue
  425. try:
  426. payload = json.loads(row.get("payload_json") or "{}")
  427. except Exception:
  428. continue
  429. snapshot = _local_breakout_snapshot(payload)
  430. sample_ts = _parse_timestamp(row.get("created_at") or payload.get("generated_at"))
  431. if sample_ts is None:
  432. continue
  433. if snapshot.get("phase") not in {"developing", "confirmed"}:
  434. continue
  435. if str(snapshot.get("meso_bias") or "neutral") != current_direction:
  436. continue
  437. if str(snapshot.get("macro_bias") or "mixed") != str(current_breakout.get("macro_bias") or "mixed"):
  438. continue
  439. qualifying_samples += 1
  440. if oldest_match is None:
  441. oldest_match = sample_ts
  442. same_direction_seconds = int((current_ts - oldest_match).total_seconds()) if oldest_match else 0
  443. promoted = current_breakout.get("phase") == "developing" and qualifying_samples >= 2 and same_direction_seconds >= min(window_seconds, 8 * 60)
  444. return {
  445. "window_seconds": window_seconds,
  446. "samples_considered": len(recent_states),
  447. "qualifying_samples": qualifying_samples,
  448. "same_direction_seconds": max(0, same_direction_seconds),
  449. "promoted_to_confirmed": promoted,
  450. }
  451. def _grid_breakout_pressure(narrative_payload: dict[str, Any], history_window: dict[str, Any] | None = None) -> dict[str, Any]:
  452. argus_context = _argus_decision_context(narrative_payload)
  453. breakout = _local_breakout_snapshot(narrative_payload)
  454. memory = _breakout_memory(narrative_payload, history_window, breakout)
  455. phase = str(breakout.get("phase") or "none")
  456. if memory["promoted_to_confirmed"]:
  457. phase = "confirmed"
  458. persistent = phase == "confirmed"
  459. return {
  460. "persistent": persistent,
  461. "phase": phase,
  462. "score": breakout["score"],
  463. "micro_impulse": breakout["micro_impulse"],
  464. "micro_bias": breakout["micro_bias"],
  465. "meso_structure": breakout["meso_structure"],
  466. "meso_bias": breakout["meso_bias"],
  467. "macro_bias": breakout["macro_bias"],
  468. "alignment": breakout["alignment"],
  469. "friction": breakout["friction"],
  470. "time_window_memory": memory,
  471. "argus_regime": argus_context["regime"],
  472. "argus_confidence": argus_context["confidence"],
  473. "argus_compression_active": argus_context["compression_active"],
  474. }
  475. def _select_current_primary(strategies: list[dict[str, Any]]) -> dict[str, Any] | None:
  476. primaries = [s for s in strategies if s["strategy_type"] in {"grid_trader", "trend_follower", "exposure_protector"} and s.get("mode") != "off"]
  477. if not primaries:
  478. return None
  479. active = next((s for s in primaries if s.get("mode") == "active"), None)
  480. if active:
  481. return active
  482. return primaries[0]
  483. def _inventory_breakout_is_directionally_compatible(inventory_state: str, breakout: dict[str, Any]) -> bool:
  484. inventory_state = _inventory_state_label(inventory_state)
  485. macro_bias = str(breakout.get("macro_bias") or "mixed")
  486. meso_bias = str(breakout.get("meso_bias") or "neutral")
  487. bullish = macro_bias == "bullish" and meso_bias == "bullish"
  488. bearish = macro_bias == "bearish" and meso_bias == "bearish"
  489. if bullish and inventory_state in {"depleted_base_side", "quote_heavy"}:
  490. return True
  491. if bearish and inventory_state in {"depleted_quote_side", "base_heavy"}:
  492. return True
  493. return False
  494. def _trend_cooling_edge(narrative_payload: dict[str, Any], wallet_state: dict[str, Any]) -> bool:
  495. if not wallet_state.get("rebalance_needed"):
  496. return False
  497. scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
  498. micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
  499. meso = scoped.get("meso") if isinstance(scoped.get("meso"), dict) else {}
  500. micro_impulse = str(micro.get("impulse") or "mixed")
  501. micro_bias = str(micro.get("trend_bias") or "mixed")
  502. micro_location = str(micro.get("location") or "unknown")
  503. micro_reversal_risk = str(micro.get("reversal_risk") or "low")
  504. meso_bias = str(meso.get("momentum_bias") or "neutral")
  505. meso_structure = str(meso.get("structure") or "rotation")
  506. inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
  507. early_reversal_warning = micro_reversal_risk in {"medium", "high"}
  508. bullish_cooling = (
  509. inventory_state in {"base_heavy", "critically_unbalanced"}
  510. and meso_structure == "trend_continuation"
  511. and meso_bias == "bullish"
  512. and (micro_impulse == "mixed" or early_reversal_warning)
  513. and micro_bias in {"mixed", "bearish", "bullish"}
  514. and micro_location in {"near_upper_band", "upper_half", "centered"}
  515. )
  516. bearish_cooling = (
  517. inventory_state in {"quote_heavy", "critically_unbalanced"}
  518. and meso_structure == "trend_continuation"
  519. and meso_bias == "bearish"
  520. and (micro_impulse == "mixed" or early_reversal_warning)
  521. and micro_bias in {"mixed", "bullish", "bearish"}
  522. and micro_location in {"near_lower_band", "lower_half", "centered"}
  523. )
  524. return bullish_cooling or bearish_cooling
  525. def _grid_fill_proximity(strategy: dict[str, Any], narrative_payload: dict[str, Any]) -> dict[str, Any]:
  526. state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
  527. orders = state.get("orders") if isinstance(state.get("orders"), list) else []
  528. features = narrative_payload.get("features_by_timeframe") if isinstance(narrative_payload.get("features_by_timeframe"), dict) else {}
  529. micro_raw = features.get("1m", {}).get("raw", {}) if isinstance(features.get("1m"), dict) else {}
  530. current_price = _safe_float(micro_raw.get("price") or state.get("last_price") or state.get("center_price"))
  531. atr_percent = _safe_float(micro_raw.get("atr_percent")) or 0.0
  532. if not current_price or current_price <= 0:
  533. return {"near_fill": False}
  534. sell_prices: list[float] = []
  535. buy_prices: list[float] = []
  536. for order in orders:
  537. if not isinstance(order, dict):
  538. continue
  539. if str(order.get("status") or "open").lower() not in {"open", "live", "active"}:
  540. continue
  541. price = _safe_float(order.get("price"))
  542. if price is None or price <= 0:
  543. continue
  544. side = str(order.get("side") or "").lower()
  545. if side == "sell" and price >= current_price:
  546. sell_prices.append(price)
  547. elif side == "buy" and price <= current_price:
  548. buy_prices.append(price)
  549. next_sell = min(sell_prices) if sell_prices else None
  550. next_buy = max(buy_prices) if buy_prices else None
  551. next_sell_distance_pct = (((next_sell - current_price) / current_price) * 100.0) if next_sell else None
  552. next_buy_distance_pct = (((current_price - next_buy) / current_price) * 100.0) if next_buy else None
  553. threshold_pct = max(0.25, atr_percent * 1.5)
  554. near_sell_fill = bool(
  555. next_sell_distance_pct is not None
  556. and next_sell_distance_pct >= 0
  557. and next_sell_distance_pct <= threshold_pct
  558. and next_buy is not None
  559. )
  560. near_buy_fill = bool(
  561. next_buy_distance_pct is not None
  562. and next_buy_distance_pct >= 0
  563. and next_buy_distance_pct <= threshold_pct
  564. and next_sell is not None
  565. )
  566. near_fill_side: str | None = None
  567. if near_sell_fill and near_buy_fill:
  568. near_fill_side = "sell" if (next_sell_distance_pct or 0.0) <= (next_buy_distance_pct or 0.0) else "buy"
  569. elif near_sell_fill:
  570. near_fill_side = "sell"
  571. elif near_buy_fill:
  572. near_fill_side = "buy"
  573. return {
  574. "near_fill": bool(near_sell_fill or near_buy_fill),
  575. "near_fill_side": near_fill_side,
  576. "near_sell_fill": near_sell_fill,
  577. "near_buy_fill": near_buy_fill,
  578. "current_price": current_price,
  579. "next_sell": next_sell,
  580. "next_buy": next_buy,
  581. "next_sell_distance_pct": round(next_sell_distance_pct, 4) if next_sell_distance_pct is not None else None,
  582. "next_buy_distance_pct": round(next_buy_distance_pct, 4) if next_buy_distance_pct is not None else None,
  583. "threshold_pct": round(threshold_pct, 4),
  584. }
  585. def _grid_fill_fights_breakout(grid_fill: dict[str, Any], breakout: dict[str, Any]) -> bool:
  586. side = str(grid_fill.get("near_fill_side") or "")
  587. bias = str(breakout.get("meso_bias") or breakout.get("micro_bias") or "")
  588. if bias == "bullish":
  589. return side == "sell"
  590. if bias == "bearish":
  591. return side == "buy"
  592. return False
  593. def _breakout_direction(breakout: dict[str, Any], stance: str | None = None) -> str | None:
  594. meso_bias = str(breakout.get("meso_bias") or "")
  595. micro_bias = str(breakout.get("micro_bias") or "")
  596. if meso_bias in {"bullish", "bearish"}:
  597. return meso_bias
  598. if micro_bias in {"bullish", "bearish"}:
  599. return micro_bias
  600. stance_text = str(stance or "")
  601. if "bullish" in stance_text:
  602. return "bullish"
  603. if "bearish" in stance_text:
  604. return "bearish"
  605. return None
  606. def _narrative_direction(narrative: dict[str, Any]) -> str | None:
  607. stance = str(narrative.get("stance") or "")
  608. breakout = narrative.get("grid_breakout_pressure") if isinstance(narrative.get("grid_breakout_pressure"), dict) else {}
  609. direction = _breakout_direction(breakout, stance)
  610. if direction:
  611. return direction
  612. if stance in {"constructive_bullish", "cautious_bullish", "fragile_bullish"}:
  613. return "bullish"
  614. if stance in {"constructive_bearish", "cautious_bearish", "fragile_bearish"}:
  615. return "bearish"
  616. return None
  617. def _strategy_trade_side(strategy: dict[str, Any]) -> str:
  618. config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
  619. state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
  620. side = str(config.get("trade_side") or state.get("trade_side") or strategy.get("trade_side") or "both").strip().lower()
  621. return side if side in {"buy", "sell", "both"} else "both"
  622. def _trend_handoff_level_threshold(breakout: dict[str, Any]) -> float:
  623. memory = breakout.get("time_window_memory") if isinstance(breakout.get("time_window_memory"), dict) else {}
  624. if bool(memory.get("promoted_to_confirmed")):
  625. return 2.0
  626. return 2.75
  627. def _grid_switch_tradeoff(*,
  628. current_primary: dict[str, Any],
  629. wallet_state: dict[str, Any],
  630. breakout: dict[str, Any],
  631. grid_fill: dict[str, Any],
  632. grid_pressure: dict[str, Any],
  633. directional_micro_clear: bool,
  634. trend: dict[str, Any] | None,
  635. ) -> dict[str, Any]:
  636. inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
  637. open_order_count = int(current_primary.get("open_order_count") or 0)
  638. if not open_order_count:
  639. state = current_primary.get("state") if isinstance(current_primary.get("state"), dict) else {}
  640. open_order_count = int(state.get("open_order_count") or len(state.get("orders") or []) or 0)
  641. trend_score = float(trend.get("score") or 0.0) if trend else 0.0
  642. breakout_score = float(breakout.get("score") or 0.0)
  643. levels = float(grid_pressure.get("levels") or 0.0)
  644. near_fill = bool(grid_fill.get("near_fill"))
  645. fill_fights = _grid_fill_fights_breakout(grid_fill, breakout)
  646. persistent = bool(breakout.get("persistent"))
  647. trend_ready = trend_score > 0.45 and directional_micro_clear
  648. switch_benefit = 0.0
  649. if persistent:
  650. switch_benefit += 0.28
  651. if trend_ready:
  652. switch_benefit += 0.34
  653. if fill_fights:
  654. switch_benefit += 0.12
  655. if levels >= _trend_handoff_level_threshold(breakout):
  656. switch_benefit += 0.18
  657. switch_benefit += min(trend_score, 2.5) * 0.18
  658. switch_benefit += min(breakout_score, 5.0) * 0.04
  659. stay_cost = 0.0
  660. if inventory_state == "balanced":
  661. stay_cost += 0.06
  662. elif inventory_state in {"base_heavy", "quote_heavy"}:
  663. stay_cost += 0.16
  664. elif inventory_state in SEVERE_INVENTORY_STATES:
  665. stay_cost += 0.28
  666. else:
  667. stay_cost += 0.1
  668. stay_cost += min(levels, 6.0) * 0.06
  669. stay_cost += min(open_order_count, 8) * 0.025
  670. if near_fill:
  671. stay_cost += 0.06
  672. if fill_fights:
  673. stay_cost += 0.18
  674. if not persistent:
  675. stay_cost += 0.12
  676. margin = round(switch_benefit - stay_cost, 4)
  677. should_switch = persistent and trend_ready and margin > 0.0
  678. return {
  679. "trend_score": round(trend_score, 4),
  680. "breakout_score": round(breakout_score, 4),
  681. "switch_benefit": round(switch_benefit, 4),
  682. "stay_cost": round(stay_cost, 4),
  683. "margin": margin,
  684. "should_switch": should_switch,
  685. "trend_ready": trend_ready,
  686. "persistent": persistent,
  687. "levels": round(levels, 4),
  688. "open_order_count": open_order_count,
  689. "near_fill": near_fill,
  690. "fill_fights": fill_fights,
  691. "inventory_state": inventory_state,
  692. }
  693. def _grid_trend_pressure(strategy: dict[str, Any], narrative_payload: dict[str, Any]) -> dict[str, Any]:
  694. state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
  695. config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
  696. features = narrative_payload.get("features_by_timeframe") if isinstance(narrative_payload.get("features_by_timeframe"), dict) else {}
  697. micro_raw = features.get("1m", {}).get("raw", {}) if isinstance(features.get("1m"), dict) else {}
  698. current_price = _safe_float(micro_raw.get("price") or state.get("last_price") or state.get("center_price"))
  699. center_price = _safe_float(state.get("center_price") or state.get("last_price"))
  700. step_pct = _safe_float(config.get("grid_step_pct") or state.get("grid_step_pct") or state.get("recenter_pct_live")) or 0.0
  701. if not current_price or not center_price or current_price <= 0 or center_price <= 0 or step_pct <= 0:
  702. return {"levels": 0.0, "rounded_levels": 0, "direction": "unknown", "current_price": current_price, "center_price": center_price, "step_pct": step_pct}
  703. distance_pct = abs(current_price - center_price) / center_price
  704. levels = distance_pct / step_pct
  705. direction = "bullish" if current_price > center_price else "bearish" if current_price < center_price else "flat"
  706. return {
  707. "levels": round(levels, 4),
  708. "rounded_levels": int(levels),
  709. "direction": direction,
  710. "current_price": current_price,
  711. "center_price": center_price,
  712. "step_pct": step_pct,
  713. "distance_pct": round(distance_pct, 4),
  714. }
  715. def _grid_can_still_work(strategy: dict[str, Any], wallet_state: dict[str, Any], grid_fill: dict[str, Any]) -> bool:
  716. supervision = strategy.get("supervision") if isinstance(strategy.get("supervision"), dict) else {}
  717. side_capacity = supervision.get("side_capacity") if isinstance(supervision.get("side_capacity"), dict) else {}
  718. buy_capacity = bool(side_capacity.get("buy", False))
  719. sell_capacity = bool(side_capacity.get("sell", False))
  720. open_order_count = int(strategy.get("open_order_count") or 0)
  721. degraded = bool(supervision.get("degraded"))
  722. inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
  723. if degraded:
  724. return False
  725. if buy_capacity or sell_capacity:
  726. return True
  727. if open_order_count > 0:
  728. return True
  729. if grid_fill.get("near_fill"):
  730. return True
  731. return inventory_state not in SEVERE_INVENTORY_STATES
  732. def _grid_is_truly_stuck_for_recovery(strategy: dict[str, Any], wallet_state: dict[str, Any], grid_fill: dict[str, Any]) -> bool:
  733. if _grid_can_still_work(strategy, wallet_state, grid_fill):
  734. return False
  735. inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
  736. return wallet_state.get("rebalance_needed") and inventory_state in SEVERE_INVENTORY_STATES
  737. def _wallet_within_rebalance_tolerance(wallet_state: dict[str, Any], tolerance: float = 0.3) -> bool:
  738. imbalance = _safe_float(wallet_state.get("imbalance_score"))
  739. if imbalance is None:
  740. base_ratio = _safe_float(wallet_state.get("base_ratio"))
  741. if base_ratio is not None:
  742. imbalance = abs(base_ratio - 0.5)
  743. if imbalance is None:
  744. return str(wallet_state.get("inventory_state") or "").lower() == "balanced"
  745. return imbalance <= tolerance
  746. def _decide_for_grid(*,
  747. current_primary: dict[str, Any],
  748. stance: str,
  749. inventory_state: str,
  750. wallet_state: dict[str, Any],
  751. breakout: dict[str, Any],
  752. grid_fill: dict[str, Any],
  753. grid_pressure: dict[str, Any],
  754. directional_micro_clear: bool,
  755. severe_imbalance: bool,
  756. trend: dict[str, Any] | None,
  757. rebalance: dict[str, Any] | None,
  758. ) -> tuple[str, str, str | None, list[str], list[str]]:
  759. action = "keep_grid"
  760. mode = "observe"
  761. target_strategy = current_primary["id"]
  762. reasons: list[str] = []
  763. blocks: list[str] = []
  764. inventory_state = _inventory_state_label(inventory_state)
  765. # Grid is the base mode. Leave it only for a persistent breakout or when
  766. # the grid has genuinely lost its ability to recover on its own.
  767. grid_friendly_stance = stance in {"neutral_rotational", "breakout_watch", "cautious_bullish", "cautious_bearish", "fragile_bullish", "fragile_bearish"}
  768. grid_can_work = _grid_can_still_work(current_primary, wallet_state, grid_fill)
  769. grid_stuck_for_recovery = _grid_is_truly_stuck_for_recovery(current_primary, wallet_state, grid_fill)
  770. persistent_breakout = bool(breakout["persistent"])
  771. breakout_phase = str(breakout.get("phase") or "none")
  772. trend_handoff_ready = bool(
  773. trend
  774. and trend["score"] > 0.45
  775. and directional_micro_clear
  776. and grid_pressure.get("levels", 0.0) >= _trend_handoff_level_threshold(breakout)
  777. )
  778. fill_fights_breakout = _grid_fill_fights_breakout(grid_fill, breakout)
  779. switch_tradeoff = _grid_switch_tradeoff(
  780. current_primary=current_primary,
  781. wallet_state=wallet_state,
  782. breakout=breakout,
  783. grid_fill=grid_fill,
  784. grid_pressure=grid_pressure,
  785. directional_micro_clear=directional_micro_clear,
  786. trend=trend,
  787. )
  788. if severe_imbalance and persistent_breakout:
  789. reasons.append("grid imbalance now coincides with persistent breakout pressure")
  790. directional_inventory = _inventory_breakout_is_directionally_compatible(inventory_state, breakout)
  791. if switch_tradeoff["should_switch"] and trend_handoff_ready and (
  792. not wallet_state.get("rebalance_needed")
  793. or directional_inventory
  794. or not rebalance
  795. or trend["score"] >= rebalance["score"]
  796. ):
  797. action = "replace_with_trend_follower"
  798. target_strategy = trend["strategy_id"]
  799. mode = "act"
  800. if directional_inventory:
  801. reasons.append("inventory posture can be absorbed by the directional handoff")
  802. reasons.append(
  803. f"switch benefit ({switch_tradeoff['switch_benefit']:.2f}) exceeds stay cost ({switch_tradeoff['stay_cost']:.2f})"
  804. )
  805. elif wallet_state.get("rebalance_needed") and rebalance and rebalance["score"] > 0.35:
  806. action = "replace_with_exposure_protector"
  807. target_strategy = rebalance["strategy_id"]
  808. mode = "act"
  809. else:
  810. action = "suspend_grid"
  811. mode = "warn"
  812. elif severe_imbalance and grid_stuck_for_recovery and not persistent_breakout and rebalance and rebalance["score"] > 0.6:
  813. action = "replace_with_exposure_protector"
  814. target_strategy = rebalance["strategy_id"]
  815. mode = "act"
  816. reasons.append("grid has lost practical recovery capacity, so inventory repair should take over")
  817. elif persistent_breakout and trend_handoff_ready and inventory_state in {"balanced", "base_heavy", "quote_heavy"}:
  818. if not switch_tradeoff["should_switch"]:
  819. reasons.append(
  820. f"breakout is persistent, but staying in grid still looks cheaper than switching (benefit {switch_tradeoff['switch_benefit']:.2f} vs cost {switch_tradeoff['stay_cost']:.2f})"
  821. )
  822. if grid_fill.get("near_fill") and fill_fights_breakout:
  823. reasons.append("nearby opposing fill is only a warning here, not enough on its own to justify the handoff")
  824. else:
  825. action = "replace_with_trend_follower"
  826. target_strategy = trend["strategy_id"] if trend else target_strategy
  827. mode = "act"
  828. if grid_fill.get("near_fill") and fill_fights_breakout:
  829. reasons.append("confirmed trend should not be delayed by a nearby grid fill that trades against the move")
  830. elif grid_fill.get("near_fill"):
  831. reasons.append("confirmed directional pressure is strong enough that nearby grid fills should not delay the trend handoff")
  832. else:
  833. reasons.append("grid should yield because directional pressure is confirmed and the trend handoff is ready")
  834. elif not persistent_breakout and grid_can_work:
  835. if breakout_phase == "developing":
  836. reasons.append("breakout pressure is developing, but grid can still work and should not be abandoned yet")
  837. else:
  838. reasons.append("grid can still operate and self-heal, so inventory skew alone should not force a rebalance handoff")
  839. elif persistent_breakout and grid_fill.get("near_fill") and inventory_state in {"balanced", "base_heavy", "quote_heavy"}:
  840. reasons.append("grid is still close to a working fill, but trend handoff is not ready enough yet")
  841. elif not grid_friendly_stance and persistent_breakout:
  842. reasons.append("grid should yield because directional pressure is persistent across scopes")
  843. if trend_handoff_ready:
  844. action = "replace_with_trend_follower"
  845. target_strategy = trend["strategy_id"]
  846. mode = "act"
  847. else:
  848. mode = "warn"
  849. if grid_pressure.get("levels", 0.0) < _trend_handoff_level_threshold(breakout):
  850. blocks.append("grid has not yet been eaten by enough levels to justify leaving it")
  851. else:
  852. blocks.append("directional pressure is rising but the micro layer is not clear enough for a trend handoff")
  853. else:
  854. reasons.append("grid can likely self-heal because breakout pressure is not yet persistent")
  855. return action, mode, target_strategy, reasons, blocks
  856. def _decide_for_trend(*,
  857. current_primary: dict[str, Any],
  858. stance: str,
  859. narrative_payload: dict[str, Any],
  860. wallet_state: dict[str, Any],
  861. grid: dict[str, Any] | None,
  862. rebalance: dict[str, Any] | None = None,
  863. ) -> tuple[str, str, str | None, list[str], list[str]]:
  864. action = "keep_trend"
  865. mode = "observe"
  866. target_strategy = current_primary["id"]
  867. reasons: list[str] = []
  868. blocks: list[str] = []
  869. # Trend should cool into rebalancing first when the wallet is skewed, then
  870. # let rebalancer hand back to grid once the inventory is healthy again.
  871. cooling = _trend_cooling_edge(narrative_payload, wallet_state)
  872. if cooling:
  873. if wallet_state.get("rebalance_needed") and rebalance:
  874. action = "replace_with_exposure_protector"
  875. target_strategy = rebalance["strategy_id"]
  876. mode = "act"
  877. reasons.append("trend has cooled and rebalancing should repair the wallet before grid resumes")
  878. elif grid and wallet_state.get("grid_ready"):
  879. action = "replace_with_grid"
  880. target_strategy = grid["strategy_id"]
  881. mode = "act"
  882. reasons.append("trend has cooled and grid can resume because no rebalancer is available")
  883. else:
  884. mode = "warn"
  885. blocks.append("edge cooling is visible but the wallet is not yet ready for grid")
  886. elif stance == "neutral_rotational":
  887. if wallet_state.get("rebalance_needed") and rebalance:
  888. action = "replace_with_exposure_protector"
  889. target_strategy = rebalance["strategy_id"]
  890. mode = "act"
  891. reasons.append("trend conditions have cooled and rebalancing should repair the wallet before grid resumes")
  892. elif grid and wallet_state.get("grid_ready"):
  893. action = "replace_with_grid"
  894. target_strategy = grid["strategy_id"]
  895. mode = "act"
  896. reasons.append("trend conditions have cooled and wallet is grid-ready again")
  897. elif wallet_state.get("rebalance_needed"):
  898. mode = "warn"
  899. blocks.append("trend has cooled but rebalancing should be the next hop")
  900. else:
  901. action = "hold_trend"
  902. blocks.append("grid candidate not strong enough yet")
  903. else:
  904. reasons.append("trend strategy still fits the directional narrative")
  905. return action, mode, target_strategy, reasons, blocks
  906. def _decide_for_rebalancer(*,
  907. current_primary: dict[str, Any],
  908. stance: str,
  909. wallet_state: dict[str, Any],
  910. grid: dict[str, Any] | None,
  911. trend: dict[str, Any] | None = None,
  912. ) -> tuple[str, str, str | None, list[str], list[str]]:
  913. action = "keep_rebalancer"
  914. mode = "observe"
  915. target_strategy = current_primary["id"]
  916. reasons: list[str] = []
  917. blocks: list[str] = []
  918. # Rebalancing is a repair phase. Once the wallet is usable again, Hermes
  919. # should prefer handing back to grid, not directly to trend.
  920. trend_strength = float(trend["score"]) if trend and isinstance(trend.get("score"), (int, float)) else 0.0
  921. if trend and trend_strength >= 1.5:
  922. blocks.append("trend is still strong enough that rebalancer should keep repairing instead of resetting to grid")
  923. elif _wallet_within_rebalance_tolerance(wallet_state, 0.3):
  924. if grid:
  925. action = "replace_with_grid"
  926. target_strategy = grid["strategy_id"]
  927. mode = "act"
  928. reasons.append("wallet is within the 0.3 rebalance tolerance, so grid can resume before perfect balance")
  929. else:
  930. blocks.append("wallet is within the rebalance tolerance but no grid candidate is available")
  931. elif wallet_state.get("grid_ready") and stance == "neutral_rotational":
  932. if grid and grid["score"] >= 0.5:
  933. action = "replace_with_grid"
  934. target_strategy = grid["strategy_id"]
  935. mode = "act"
  936. reasons.append("rebalance is complete and rotational conditions support grid again")
  937. else:
  938. blocks.append("wallet is ready but grid fit is still too weak")
  939. elif grid and grid["score"] >= 0.5:
  940. action = "replace_with_grid"
  941. target_strategy = grid["strategy_id"]
  942. mode = "act"
  943. reasons.append("trend is directional but not yet sustained, so grid can resume first")
  944. else:
  945. blocks.append("trend candidate is not strong enough yet and grid fit is not ready, so rebalancer should not hand directly back to trend")
  946. return action, mode, target_strategy, reasons, blocks
  947. def make_decision(*, concern: dict[str, Any], narrative_payload: dict[str, Any], wallet_state: dict[str, Any], strategies: list[dict[str, Any]], history_window: dict[str, Any] | None = None) -> DecisionSnapshot:
  948. normalized = [normalize_strategy_snapshot(s) for s in strategies if str(s.get("account_id") or "") == str(concern.get("account_id") or "")]
  949. breakout = _grid_breakout_pressure(narrative_payload, history_window=history_window)
  950. narrative_for_scoring = {**narrative_payload, "grid_breakout_pressure": breakout}
  951. fit_reports = [score_strategy_fit(strategy=s, narrative=narrative_for_scoring, wallet_state=wallet_state) for s in normalized]
  952. ranked = sorted(fit_reports, key=lambda item: item["score"], reverse=True)
  953. current_primary = _select_current_primary(normalized)
  954. best = ranked[0] if ranked else None
  955. stance = str(narrative_payload.get("stance") or "neutral_rotational")
  956. inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
  957. scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
  958. micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
  959. micro_impulse = str(micro.get("impulse") or "mixed")
  960. micro_bias = str(micro.get("trend_bias") or "mixed")
  961. micro_reversal_risk = str(micro.get("reversal_risk") or "low")
  962. bullish_micro_clear = micro_impulse == "up" and micro_bias == "bullish" and micro_reversal_risk != "high"
  963. bearish_micro_clear = micro_impulse == "down" and micro_bias == "bearish" and micro_reversal_risk != "high"
  964. breakout_direction = _breakout_direction(breakout, stance)
  965. directional_micro_clear = bullish_micro_clear if breakout_direction == "bullish" else bearish_micro_clear if breakout_direction == "bearish" else False
  966. grid_fill = _grid_fill_proximity(current_primary, narrative_payload) if current_primary and current_primary["strategy_type"] == "grid_trader" else {"near_fill": False}
  967. grid_pressure = _grid_trend_pressure(current_primary, narrative_payload) if current_primary and current_primary["strategy_type"] == "grid_trader" else {"levels": 0.0, "rounded_levels": 0, "direction": "unknown"}
  968. severe_imbalance = inventory_state in SEVERE_INVENTORY_STATES
  969. action = "hold"
  970. mode = "observe"
  971. target_strategy = current_primary.get("id") if current_primary else (best.get("strategy_id") if best else None)
  972. reasons: list[str] = []
  973. blocks: list[str] = []
  974. trend = next((r for r in ranked if r["strategy_type"] == "trend_follower"), None)
  975. rebalance = next((r for r in ranked if r["strategy_type"] == "exposure_protector"), None)
  976. grid = next((r for r in ranked if r["strategy_type"] == "grid_trader"), None)
  977. switch_tradeoff: dict[str, Any] = {}
  978. if current_primary and current_primary["strategy_type"] == "grid_trader":
  979. action, mode, target_strategy, reasons, blocks = _decide_for_grid(
  980. current_primary=current_primary,
  981. stance=stance,
  982. inventory_state=inventory_state,
  983. wallet_state=wallet_state,
  984. breakout=breakout,
  985. grid_fill=grid_fill,
  986. grid_pressure=grid_pressure,
  987. directional_micro_clear=directional_micro_clear,
  988. severe_imbalance=severe_imbalance,
  989. trend=trend,
  990. rebalance=rebalance,
  991. )
  992. switch_tradeoff = _grid_switch_tradeoff(
  993. current_primary=current_primary,
  994. wallet_state=wallet_state,
  995. breakout=breakout,
  996. grid_fill=grid_fill,
  997. grid_pressure=grid_pressure,
  998. directional_micro_clear=directional_micro_clear,
  999. trend=trend,
  1000. )
  1001. elif current_primary and current_primary["strategy_type"] == "trend_follower":
  1002. action, mode, target_strategy, reasons, blocks = _decide_for_trend(
  1003. current_primary=current_primary,
  1004. stance=stance,
  1005. narrative_payload=narrative_payload,
  1006. wallet_state=wallet_state,
  1007. grid=grid,
  1008. rebalance=rebalance,
  1009. )
  1010. elif current_primary and current_primary["strategy_type"] == "exposure_protector":
  1011. action, mode, target_strategy, reasons, blocks = _decide_for_rebalancer(
  1012. current_primary=current_primary,
  1013. stance=stance,
  1014. wallet_state=wallet_state,
  1015. grid=grid,
  1016. trend=trend,
  1017. )
  1018. else:
  1019. if best and best["score"] >= 0.55:
  1020. action = f"enable_{best['strategy_type']}"
  1021. target_strategy = best["strategy_id"]
  1022. mode = "act"
  1023. reasons.extend(best["reasons"])
  1024. else:
  1025. action = "wait"
  1026. mode = "observe"
  1027. blocks.append("no strategy is yet a strong enough fit")
  1028. reason_summary = reasons[0] if reasons else (blocks[0] if blocks else "strategy posture unchanged")
  1029. confidence = float(narrative_payload.get("confidence") or 0.4)
  1030. if action.startswith("replace_with") or action.startswith("enable_"):
  1031. confidence += 0.08
  1032. if wallet_state.get("rebalance_needed") and "grid" in action:
  1033. confidence -= 0.08
  1034. confidence = round(_clamp(confidence, 0.2, 0.95), 3)
  1035. payload = {
  1036. "generated_at": datetime.now(timezone.utc).isoformat(),
  1037. "wallet_state": wallet_state,
  1038. "narrative_stance": stance,
  1039. "strategy_fit_ranking": ranked,
  1040. "current_primary_strategy": current_primary.get("id") if current_primary else None,
  1041. "argus_decision_context": _argus_decision_context(narrative_payload),
  1042. "history_window": history_window or {},
  1043. "grid_breakout_pressure": breakout,
  1044. "grid_fill_context": grid_fill,
  1045. "grid_switch_tradeoff": switch_tradeoff if current_primary and current_primary["strategy_type"] == "grid_trader" else {},
  1046. "reason_chain": reasons,
  1047. "blocks": blocks,
  1048. "decision_version": 2,
  1049. }
  1050. return DecisionSnapshot(
  1051. mode=mode,
  1052. action=action,
  1053. target_strategy=target_strategy,
  1054. reason_summary=reason_summary,
  1055. confidence=confidence,
  1056. requires_action=mode == "act",
  1057. payload=payload,
  1058. )