decision_engine.py 89 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893
  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 _decision_profile_config(decision_profile: dict[str, Any] | None) -> dict[str, Any]:
  34. if not isinstance(decision_profile, dict):
  35. return {}
  36. config = decision_profile.get("config")
  37. if isinstance(config, dict):
  38. return config
  39. raw = decision_profile.get("config_json")
  40. if isinstance(raw, str) and raw.strip():
  41. try:
  42. parsed = json.loads(raw)
  43. if isinstance(parsed, dict):
  44. return parsed
  45. except Exception:
  46. return {}
  47. return {}
  48. def _inventory_state_label(value: Any) -> str:
  49. state = str(value or "unknown").strip().lower()
  50. aliases = {
  51. "critical": "critically_unbalanced",
  52. "critically_imbalanced": "critically_unbalanced",
  53. "depleted_base": "depleted_base_side",
  54. "depleted_quote": "depleted_quote_side",
  55. "one_sided_base": "depleted_base_side",
  56. "one_sided_quote": "depleted_quote_side",
  57. }
  58. return aliases.get(state, state)
  59. def _timeframe_direction(feature: dict[str, Any] | None) -> str:
  60. if not isinstance(feature, dict):
  61. return "mixed"
  62. trend = feature.get("trend") if isinstance(feature.get("trend"), dict) else {}
  63. momentum = feature.get("momentum") if isinstance(feature.get("momentum"), dict) else {}
  64. alignment = str(trend.get("alignment") or "")
  65. if alignment in {"fully_bullish", "bullish_pullback"}:
  66. return "bullish"
  67. if alignment in {"fully_bearish", "bearish_pullback"}:
  68. return "bearish"
  69. bias_score = _safe_float(trend.get("bias_score"))
  70. if bias_score is not None:
  71. if bias_score >= 0.55:
  72. return "bullish"
  73. if bias_score <= -0.55:
  74. return "bearish"
  75. impulse = str(momentum.get("impulse") or "")
  76. if impulse == "up":
  77. return "bullish"
  78. if impulse == "down":
  79. return "bearish"
  80. return "mixed"
  81. def _short_term_trend_dislocated(narrative_payload: dict[str, Any]) -> bool:
  82. features = narrative_payload.get("features_by_timeframe") if isinstance(narrative_payload.get("features_by_timeframe"), dict) else {}
  83. short_dirs = [_timeframe_direction(features.get(tf)) for tf in ("1m", "5m")]
  84. higher_dirs = [_timeframe_direction(features.get(tf)) for tf in ("15m", "1h", "4h", "1d")]
  85. short_clean = [d for d in short_dirs if d in {"bullish", "bearish"}]
  86. higher_clean = [d for d in higher_dirs if d in {"bullish", "bearish"}]
  87. if not short_clean:
  88. return bool(higher_clean) and len(set(higher_clean)) == 1
  89. if any(d == "mixed" for d in short_dirs):
  90. return bool(higher_clean) and len(set(higher_clean)) == 1
  91. short_direction = short_clean[0] if len(set(short_clean)) == 1 else "mixed"
  92. if short_direction == "mixed":
  93. return True
  94. if not higher_clean:
  95. return False
  96. higher_direction = higher_clean[0] if len(set(higher_clean)) == 1 else "mixed"
  97. return higher_direction in {"bullish", "bearish"} and short_direction != higher_direction
  98. def _short_term_trend_manifest_score(narrative_payload: dict[str, Any], direction: str) -> float:
  99. features = narrative_payload.get("features_by_timeframe") if isinstance(narrative_payload.get("features_by_timeframe"), dict) else {}
  100. if direction not in {"bullish", "bearish"}:
  101. return 0.0
  102. total = 0.0
  103. seen = 0
  104. for timeframe in ("1m", "5m"):
  105. feature = features.get(timeframe) if isinstance(features.get(timeframe), dict) else None
  106. if not feature:
  107. continue
  108. seen += 1
  109. trend = feature.get("trend") if isinstance(feature.get("trend"), dict) else {}
  110. if not trend:
  111. local = 0.68
  112. else:
  113. alignment = str(trend.get("alignment") or "")
  114. strength = _safe_float(trend.get("strength")) or 0.0
  115. bias_score = abs(_safe_float(trend.get("bias_score")) or 0.0)
  116. local = 0.0
  117. if direction == "bullish":
  118. if alignment == "fully_bullish":
  119. local = 1.0
  120. elif alignment == "bullish_pullback":
  121. local = 0.62
  122. else:
  123. if alignment == "fully_bearish":
  124. local = 1.0
  125. elif alignment == "bearish_pullback":
  126. local = 0.62
  127. if local == 0.0:
  128. short_direction = _timeframe_direction(feature)
  129. if short_direction == direction:
  130. local = 0.34
  131. elif short_direction == "mixed":
  132. local = 0.12
  133. if strength >= 0.55:
  134. local += 0.16
  135. elif strength <= 0.2:
  136. local -= 0.08
  137. if bias_score >= 0.75:
  138. local += 0.08
  139. elif bias_score <= 0.2:
  140. local -= 0.04
  141. total += _clamp(local, 0.0, 1.0)
  142. if not seen:
  143. return 0.0
  144. return round(total / seen, 4)
  145. SEVERE_INVENTORY_STATES = {"critically_unbalanced", "depleted_base_side", "depleted_quote_side"}
  146. REBALANCE_INVENTORY_STATES = {"base_heavy", "quote_heavy", *SEVERE_INVENTORY_STATES}
  147. def _infer_market_pair(concern: dict[str, Any]) -> tuple[str, str]:
  148. base = str(concern.get("base_currency") or "").strip().upper()
  149. quote = str(concern.get("quote_currency") or "").strip().upper()
  150. if base and quote:
  151. return base, quote
  152. market = str(concern.get("market_symbol") or "").strip().upper().replace("/", "").replace("-", "")
  153. for suffix in ("USDT", "USDC", "USD", "EUR", "BTC", "ETH"):
  154. if market.endswith(suffix) and len(market) > len(suffix):
  155. inferred_base = market[:-len(suffix)]
  156. inferred_quote = suffix
  157. return base or inferred_base, quote or inferred_quote
  158. return base or market, quote or "USD"
  159. 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]:
  160. """Summarize inventory health for strategy switching.
  161. The key output is whether the wallet is balanced enough for range/grid
  162. harvesting, or so skewed that Hermes should prefer trend capture or
  163. rebalancing before grid is allowed again.
  164. """
  165. balances = account_info.get("balances") if isinstance(account_info.get("balances"), list) else []
  166. base, quote = _infer_market_pair(concern)
  167. base_available = 0.0
  168. quote_available = 0.0
  169. for item in balances:
  170. if not isinstance(item, dict):
  171. continue
  172. asset = str(item.get("asset_code") or item.get("asset") or "").upper()
  173. amount = _safe_float(item.get("available") if item.get("available") is not None else item.get("total"))
  174. if amount is None:
  175. continue
  176. if asset == base:
  177. base_available = amount
  178. elif asset == quote:
  179. quote_available = amount
  180. reserved_base = 0.0
  181. reserved_quote = 0.0
  182. for strategy in strategies or []:
  183. if not isinstance(strategy, dict):
  184. continue
  185. if str(strategy.get("account_id") or "").strip() != str(concern.get("account_id") or "").strip():
  186. continue
  187. market_symbol = str(strategy.get("market_symbol") or "").strip().lower()
  188. if market_symbol and market_symbol != str(concern.get("market_symbol") or "").strip().lower():
  189. continue
  190. if str(strategy.get("mode") or "off") == "off":
  191. continue
  192. state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
  193. orders = state.get("orders") if isinstance(state.get("orders"), list) else []
  194. for order in orders:
  195. if not isinstance(order, dict):
  196. continue
  197. if str(order.get("status") or "open").lower() not in {"open", "live", "active"}:
  198. continue
  199. side = str(order.get("side") or "").lower()
  200. amount = _safe_float(order.get("amount") or order.get("amount_remaining")) or 0.0
  201. order_price = _safe_float(order.get("price")) or price or 0.0
  202. if side == "sell":
  203. reserved_base += amount
  204. elif side == "buy":
  205. reserved_quote += amount * order_price
  206. price = price or 0.0
  207. effective_base = base_available + reserved_base
  208. effective_quote = quote_available + reserved_quote
  209. base_value = effective_base * price if price > 0 else 0.0
  210. quote_value = effective_quote
  211. total_value = base_value + quote_value
  212. base_ratio = (base_value / total_value) if total_value > 0 else 0.5
  213. quote_ratio = (quote_value / total_value) if total_value > 0 else 0.5
  214. imbalance = abs(base_ratio - 0.5)
  215. if total_value <= 0:
  216. inventory_state = "unknown"
  217. elif base_ratio <= 0.02:
  218. inventory_state = "depleted_base_side"
  219. elif quote_ratio <= 0.02:
  220. inventory_state = "depleted_quote_side"
  221. elif base_ratio < 0.08:
  222. inventory_state = "critically_unbalanced"
  223. elif quote_ratio < 0.08:
  224. inventory_state = "critically_unbalanced"
  225. elif imbalance >= 0.35:
  226. inventory_state = "critically_unbalanced"
  227. elif base_ratio > 0.62:
  228. inventory_state = "base_heavy"
  229. elif quote_ratio > 0.62:
  230. inventory_state = "quote_heavy"
  231. else:
  232. inventory_state = "balanced"
  233. grid_ready = inventory_state == "balanced"
  234. rebalance_needed = inventory_state in REBALANCE_INVENTORY_STATES
  235. return {
  236. "generated_at": datetime.now(timezone.utc).isoformat(),
  237. "base_currency": base,
  238. "quote_currency": quote,
  239. "base_available": round(base_available, 8),
  240. "quote_available": round(quote_available, 8),
  241. "base_reserved": round(reserved_base, 8),
  242. "quote_reserved": round(reserved_quote, 8),
  243. "base_effective": round(effective_base, 8),
  244. "quote_effective": round(effective_quote, 8),
  245. "base_value": round(base_value, 4),
  246. "quote_value": round(quote_value, 4),
  247. "total_value": round(total_value, 4),
  248. "base_ratio": round(base_ratio, 4),
  249. "quote_ratio": round(quote_ratio, 4),
  250. "imbalance_score": round(imbalance, 4),
  251. "inventory_state": inventory_state,
  252. "grid_ready": grid_ready,
  253. "rebalance_needed": rebalance_needed,
  254. }
  255. def normalize_strategy_snapshot(strategy: dict[str, Any]) -> dict[str, Any]:
  256. strategy_type = str(strategy.get("strategy_type") or "unknown")
  257. mode = str(strategy.get("mode") or "off")
  258. state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
  259. config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
  260. report = strategy.get("report") if isinstance(strategy.get("report"), dict) else {}
  261. report_fit = report.get("fit") if isinstance(report.get("fit"), dict) else {}
  262. report_supervision = report.get("supervision") if isinstance(report.get("supervision"), dict) else {}
  263. report_state = report.get("state") if isinstance(report.get("state"), dict) else {}
  264. # Stable minimum contract used by Hermes while the trader-side strategy
  265. # metadata evolves. These values can later be sourced directly from richer
  266. # reports, but the decision layer keeps a normalized shape from day one.
  267. defaults = {
  268. "grid_trader": {
  269. "role": "primary",
  270. "inventory_behavior": "balanced",
  271. "requires_rebalance_before_start": False,
  272. "requires_rebalance_before_stop": False,
  273. "safe_when_unbalanced": False,
  274. "can_run_with": ["exposure_protector"],
  275. },
  276. "trend_follower": {
  277. "role": "primary",
  278. "inventory_behavior": "accumulative_long",
  279. "requires_rebalance_before_start": False,
  280. "requires_rebalance_before_stop": False,
  281. "safe_when_unbalanced": True,
  282. "can_run_with": [],
  283. "trade_side": "both",
  284. },
  285. "exposure_protector": {
  286. "role": "rebalancing",
  287. "inventory_behavior": "rebalancing",
  288. "requires_rebalance_before_start": False,
  289. "requires_rebalance_before_stop": False,
  290. "safe_when_unbalanced": True,
  291. "can_run_with": [],
  292. "rebalance_tolerance": 0.3,
  293. },
  294. }
  295. contract = defaults.get(strategy_type, {
  296. "role": "primary",
  297. "inventory_behavior": "unknown",
  298. "requires_rebalance_before_start": False,
  299. "requires_rebalance_before_stop": False,
  300. "safe_when_unbalanced": True,
  301. "can_run_with": [],
  302. })
  303. contract = {**contract, **report_fit}
  304. return {
  305. "id": strategy.get("id"),
  306. "strategy_type": strategy_type,
  307. "mode": mode,
  308. "enabled": mode != "off",
  309. "status": strategy.get("status") or ("running" if mode != "off" else "stopped"),
  310. "market_symbol": strategy.get("market_symbol"),
  311. "account_id": strategy.get("account_id"),
  312. "open_order_count": int(state.get("open_order_count") or report_state.get("open_order_count") or strategy.get("open_order_count") or 0),
  313. "last_action": state.get("last_action") or report_state.get("last_action") or strategy.get("last_side"),
  314. "last_error": state.get("last_error") or report_state.get("last_error") or "",
  315. "contract": contract,
  316. "trade_side": str(config.get("trade_side") or contract.get("trade_side") or "both"),
  317. "supervision": report_supervision,
  318. "config": config,
  319. "state": {**report_state, **state},
  320. }
  321. def _argus_decision_context(narrative_payload: dict[str, Any]) -> dict[str, Any]:
  322. argus = narrative_payload.get("argus_context") if isinstance(narrative_payload.get("argus_context"), dict) else {}
  323. regime = str(argus.get("regime") or argus.get("snapshot_regime") or "").strip()
  324. confidence_raw = argus.get("regime_confidence") if argus.get("regime_confidence") is not None else argus.get("snapshot_confidence")
  325. confidence = float(confidence_raw) if isinstance(confidence_raw, (int, float)) else 0.0
  326. components = argus.get("regime_components") if isinstance(argus.get("regime_components"), dict) else {}
  327. if not components:
  328. components = argus.get("snapshot_components") if isinstance(argus.get("snapshot_components"), dict) else {}
  329. compression = float(components.get("compression") or 0.0)
  330. compression_active = regime == "compression" and confidence >= 0.55 and compression >= 0.65
  331. return {
  332. "regime": regime,
  333. "confidence": round(confidence, 4),
  334. "components": components,
  335. "compression": round(compression, 4),
  336. "compression_active": compression_active,
  337. }
  338. def _parse_timestamp(value: Any) -> datetime | None:
  339. text = str(value or "").strip()
  340. if not text:
  341. return None
  342. if text.endswith("Z"):
  343. text = text[:-1] + "+00:00"
  344. try:
  345. parsed = datetime.fromisoformat(text)
  346. except Exception:
  347. return None
  348. if parsed.tzinfo is None:
  349. return parsed.replace(tzinfo=timezone.utc)
  350. return parsed.astimezone(timezone.utc)
  351. def _recent_switch_cooldown_active(history_window: dict[str, Any] | None, concern_id: str, cooldown_seconds: int) -> tuple[bool, float | None, str | None]:
  352. if cooldown_seconds <= 0:
  353. return False, None, None
  354. rows = history_window.get("recent_decisions") if isinstance(history_window, dict) and isinstance(history_window.get("recent_decisions"), list) else []
  355. now = datetime.now(timezone.utc)
  356. for row in rows:
  357. if not isinstance(row, dict):
  358. continue
  359. if concern_id and str(row.get("concern_id") or "") != concern_id:
  360. continue
  361. mode = str(row.get("mode") or "").lower()
  362. action = str(row.get("action") or "")
  363. target = str(row.get("target_strategy") or "")
  364. if mode != "act" or not action or not target:
  365. continue
  366. created = _parse_timestamp(row.get("created_at"))
  367. if not created:
  368. continue
  369. elapsed = (now - created).total_seconds()
  370. if elapsed < cooldown_seconds:
  371. return True, round(max(cooldown_seconds - elapsed, 0.0), 1), action
  372. return False, None, action
  373. return False, None, None
  374. def score_strategy_fit(*, strategy: dict[str, Any], narrative: dict[str, Any], wallet_state: dict[str, Any]) -> dict[str, Any]:
  375. stance = str(narrative.get("stance") or "neutral_rotational")
  376. opportunity_map = narrative.get("opportunity_map") if isinstance(narrative.get("opportunity_map"), dict) else {}
  377. breakout_pressure = narrative.get("grid_breakout_pressure") if isinstance(narrative.get("grid_breakout_pressure"), dict) else {}
  378. breakout_phase = str(breakout_pressure.get("phase") or "none")
  379. continuation = float(opportunity_map.get("continuation") or 0.0)
  380. mean_reversion = float(opportunity_map.get("mean_reversion") or 0.0)
  381. reversal = float(opportunity_map.get("reversal") or 0.0)
  382. wait = float(opportunity_map.get("wait") or 0.0)
  383. inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
  384. argus_context = _argus_decision_context(narrative)
  385. strategy_type = strategy["strategy_type"]
  386. supervision = strategy.get("supervision") if isinstance(strategy.get("supervision"), dict) else {}
  387. inventory_pressure = str(supervision.get("inventory_pressure") or "")
  388. capacity_available = bool(supervision.get("capacity_available"))
  389. side_capacity = supervision.get("side_capacity") if isinstance(supervision.get("side_capacity"), dict) else {}
  390. score = 0.0
  391. reasons: list[str] = []
  392. blocks: list[str] = []
  393. if strategy_type == "grid_trader":
  394. score += mean_reversion * 1.8
  395. if stance in {"neutral_rotational", "breakout_watch"}:
  396. score += 0.45
  397. reasons.append("narrative still supports rotational structure")
  398. if continuation >= 0.45:
  399. score -= 0.8
  400. blocks.append("continuation pressure is too strong for safe grid harvesting")
  401. if inventory_state != "balanced":
  402. score -= 1.0
  403. blocks.append(f"wallet is not grid-ready: {inventory_state}")
  404. else:
  405. reasons.append("wallet is balanced enough for two-sided harvesting")
  406. if not capacity_available:
  407. score -= 0.25
  408. blocks.append("grid report shows one-sided capacity")
  409. if side_capacity and not (bool(side_capacity.get("buy", True)) and bool(side_capacity.get("sell", True))):
  410. score -= 0.25
  411. blocks.append("grid side capacity is asymmetric")
  412. if argus_context["compression_active"]:
  413. score += 0.2
  414. reasons.append("Argus compression supports staying selective with grid")
  415. elif strategy_type == "trend_follower":
  416. score += continuation * 1.9
  417. trade_side = _strategy_trade_side(strategy)
  418. narrative_direction = _narrative_direction(narrative)
  419. if stance in {"constructive_bullish", "cautious_bullish", "constructive_bearish", "cautious_bearish"}:
  420. score += 0.5
  421. reasons.append("narrative supports directional continuation")
  422. if trade_side == "buy":
  423. if narrative_direction == "bullish":
  424. score += 0.6
  425. reasons.append("buy-side trend instance matches bullish direction")
  426. elif narrative_direction == "bearish":
  427. score -= 0.9
  428. blocks.append("buy-side trend instance conflicts with bearish direction")
  429. elif trade_side == "sell":
  430. if narrative_direction == "bearish":
  431. score += 0.6
  432. reasons.append("sell-side trend instance matches bearish direction")
  433. elif narrative_direction == "bullish":
  434. score -= 0.9
  435. blocks.append("sell-side trend instance conflicts with bullish direction")
  436. if breakout_phase == "confirmed":
  437. score += 0.45
  438. reasons.append("confirmed breakout pressure supports directional continuation")
  439. elif breakout_phase == "developing":
  440. score += 0.2
  441. reasons.append("breakout pressure is developing in trend's favor")
  442. if wait >= 0.45 and breakout_phase != "confirmed":
  443. score -= 0.35
  444. blocks.append("market still has too much wait/uncertainty for trend commitment")
  445. if inventory_state in SEVERE_INVENTORY_STATES:
  446. score -= 0.25
  447. blocks.append("wallet may be too skewed for clean directional scaling")
  448. if inventory_pressure in {"base_heavy", "quote_heavy"}:
  449. score -= 0.1
  450. blocks.append("trend report shows rising inventory pressure")
  451. if not capacity_available:
  452. score -= 0.1
  453. blocks.append("trend strength is below its own capacity threshold")
  454. if trade_side == "both" and narrative_direction in {"bullish", "bearish"}:
  455. score += 0.15
  456. reasons.append("generic trend instance can follow either side")
  457. if argus_context["compression_active"] and breakout_phase != "confirmed":
  458. score -= 0.15
  459. blocks.append("Argus compression says the broader tape is still range-like")
  460. elif strategy_type == "exposure_protector":
  461. score += reversal * 0.4 + wait * 0.5
  462. if wallet_state.get("rebalance_needed"):
  463. score += 1.1
  464. reasons.append("wallet imbalance calls for rebalancing protection")
  465. if inventory_state in SEVERE_INVENTORY_STATES:
  466. score += 0.45
  467. reasons.append("inventory drift is high enough to justify defensive action")
  468. if stance in {"constructive_bullish", "constructive_bearish"} and continuation > 0.65:
  469. score -= 0.2
  470. if inventory_pressure in {"critical", "elevated"}:
  471. score += 0.25
  472. reasons.append("protector reports active inventory pressure")
  473. if strategy.get("last_error"):
  474. score -= 0.25
  475. blocks.append("strategy recently reported an error")
  476. if bool(supervision.get("degraded")):
  477. score -= 0.15
  478. blocks.append("strategy self-reports degraded supervision state")
  479. return {
  480. "strategy_id": strategy.get("id"),
  481. "strategy_type": strategy_type,
  482. "score": round(score, 4),
  483. "reasons": reasons,
  484. "blocks": blocks,
  485. "enabled": strategy.get("enabled", False),
  486. }
  487. def _breakout_phase_from_score(score: float) -> str:
  488. if score >= 3.45:
  489. return "confirmed"
  490. if score >= 2.45:
  491. return "developing"
  492. if score >= 1.4:
  493. return "probing"
  494. return "none"
  495. def _local_breakout_snapshot(narrative_payload: dict[str, Any]) -> dict[str, Any]:
  496. scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
  497. cross = narrative_payload.get("cross_scope_summary") if isinstance(narrative_payload.get("cross_scope_summary"), 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. macro = scoped.get("macro") if isinstance(scoped.get("macro"), dict) else {}
  501. micro_impulse = str(micro.get("impulse") or "mixed")
  502. micro_bias = str(micro.get("trend_bias") or "mixed")
  503. meso_structure = str(meso.get("structure") or "rotation")
  504. meso_bias = str(meso.get("momentum_bias") or "neutral")
  505. macro_bias = str(macro.get("bias") or "mixed")
  506. alignment = str(cross.get("alignment") or "partial_alignment")
  507. friction = str(cross.get("friction") or "medium")
  508. micro_directional = micro_impulse in {"up", "down"} and micro_bias in {"bullish", "bearish"}
  509. meso_directional = meso_structure == "trend_continuation" and meso_bias in {"bullish", "bearish"}
  510. macro_supportive = macro_bias in {"bullish", "bearish"}
  511. score = 0.0
  512. if micro_directional:
  513. score += 1.0
  514. if meso_directional:
  515. score += 1.1
  516. if macro_supportive:
  517. score += 0.55
  518. if alignment == "micro_meso_macro_aligned":
  519. score += 0.8
  520. elif alignment == "partial_alignment":
  521. score += 0.35
  522. if friction == "low":
  523. score += 0.45
  524. elif friction == "medium":
  525. score += 0.15
  526. return {
  527. "score": round(score, 4),
  528. "phase": _breakout_phase_from_score(score),
  529. "micro_impulse": micro_impulse,
  530. "micro_bias": micro_bias,
  531. "meso_structure": meso_structure,
  532. "meso_bias": meso_bias,
  533. "macro_bias": macro_bias,
  534. "alignment": alignment,
  535. "friction": friction,
  536. }
  537. def _breakout_memory(narrative_payload: dict[str, Any], history_window: dict[str, Any] | None, current_breakout: dict[str, Any]) -> dict[str, Any]:
  538. recent_states = history_window.get("recent_states") if isinstance(history_window, dict) and isinstance(history_window.get("recent_states"), list) else []
  539. window_seconds = int(history_window.get("window_seconds") or 0) if isinstance(history_window, dict) else 0
  540. current_ts = _parse_timestamp(narrative_payload.get("generated_at")) or datetime.now(timezone.utc)
  541. current_direction = str(current_breakout.get("meso_bias") or "neutral")
  542. directional = current_direction in {"bullish", "bearish"} and current_breakout.get("meso_structure") == "trend_continuation"
  543. if not directional:
  544. return {"window_seconds": window_seconds, "samples_considered": 0, "qualifying_samples": 0, "same_direction_seconds": 0, "promoted_to_confirmed": False}
  545. qualifying_samples = 0
  546. oldest_match: datetime | None = None
  547. for row in recent_states:
  548. if not isinstance(row, dict):
  549. continue
  550. try:
  551. payload = json.loads(row.get("payload_json") or "{}")
  552. except Exception:
  553. continue
  554. snapshot = _local_breakout_snapshot(payload)
  555. sample_ts = _parse_timestamp(row.get("created_at") or payload.get("generated_at"))
  556. if sample_ts is None:
  557. continue
  558. if snapshot.get("phase") not in {"developing", "confirmed"}:
  559. continue
  560. if str(snapshot.get("meso_bias") or "neutral") != current_direction:
  561. continue
  562. if str(snapshot.get("macro_bias") or "mixed") != str(current_breakout.get("macro_bias") or "mixed"):
  563. continue
  564. qualifying_samples += 1
  565. if oldest_match is None:
  566. oldest_match = sample_ts
  567. same_direction_seconds = int((current_ts - oldest_match).total_seconds()) if oldest_match else 0
  568. promoted = current_breakout.get("phase") == "developing" and qualifying_samples >= 2 and same_direction_seconds >= min(window_seconds, 8 * 60)
  569. return {
  570. "window_seconds": window_seconds,
  571. "samples_considered": len(recent_states),
  572. "qualifying_samples": qualifying_samples,
  573. "same_direction_seconds": max(0, same_direction_seconds),
  574. "promoted_to_confirmed": promoted,
  575. }
  576. def _grid_breakout_pressure(narrative_payload: dict[str, Any], history_window: dict[str, Any] | None = None) -> dict[str, Any]:
  577. argus_context = _argus_decision_context(narrative_payload)
  578. breakout = _local_breakout_snapshot(narrative_payload)
  579. memory = _breakout_memory(narrative_payload, history_window, breakout)
  580. phase = str(breakout.get("phase") or "none")
  581. if memory["promoted_to_confirmed"]:
  582. phase = "confirmed"
  583. persistent = phase == "confirmed"
  584. return {
  585. "persistent": persistent,
  586. "phase": phase,
  587. "score": breakout["score"],
  588. "micro_impulse": breakout["micro_impulse"],
  589. "micro_bias": breakout["micro_bias"],
  590. "meso_structure": breakout["meso_structure"],
  591. "meso_bias": breakout["meso_bias"],
  592. "macro_bias": breakout["macro_bias"],
  593. "alignment": breakout["alignment"],
  594. "friction": breakout["friction"],
  595. "time_window_memory": memory,
  596. "argus_regime": argus_context["regime"],
  597. "argus_confidence": argus_context["confidence"],
  598. "argus_compression_active": argus_context["compression_active"],
  599. }
  600. def _select_current_primary(strategies: list[dict[str, Any]]) -> dict[str, Any] | None:
  601. primaries = [s for s in strategies if s["strategy_type"] in {"grid_trader", "trend_follower", "exposure_protector"} and s.get("mode") != "off"]
  602. if not primaries:
  603. return None
  604. active = next((s for s in primaries if s.get("mode") == "active"), None)
  605. if active:
  606. return active
  607. return primaries[0]
  608. def _inventory_breakout_is_directionally_compatible(inventory_state: str, breakout: dict[str, Any]) -> bool:
  609. inventory_state = _inventory_state_label(inventory_state)
  610. macro_bias = str(breakout.get("macro_bias") or "mixed")
  611. meso_bias = str(breakout.get("meso_bias") or "neutral")
  612. bullish = macro_bias == "bullish" and meso_bias == "bullish"
  613. bearish = macro_bias == "bearish" and meso_bias == "bearish"
  614. if bullish and inventory_state in {"depleted_base_side", "quote_heavy"}:
  615. return True
  616. if bearish and inventory_state in {"depleted_quote_side", "base_heavy"}:
  617. return True
  618. return False
  619. def _trend_cooling_edge(narrative_payload: dict[str, Any], wallet_state: dict[str, Any], profile_config: dict[str, Any] | None = None) -> bool:
  620. profile_config = profile_config if isinstance(profile_config, dict) else {}
  621. scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
  622. short_term_dislocated = _short_term_trend_dislocated(narrative_payload)
  623. micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
  624. meso = scoped.get("meso") if isinstance(scoped.get("meso"), dict) else {}
  625. micro_impulse = str(micro.get("impulse") or "mixed")
  626. micro_bias = str(micro.get("trend_bias") or "mixed")
  627. micro_location = str(micro.get("location") or "unknown")
  628. micro_reversal_risk = str(micro.get("reversal_risk") or "low")
  629. meso_bias = str(meso.get("momentum_bias") or "neutral")
  630. meso_structure = str(meso.get("structure") or "rotation")
  631. inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
  632. early_reversal_warning = micro_reversal_risk in {"medium", "high"}
  633. short_term_warning = short_term_dislocated and meso_structure == "trend_continuation"
  634. micro_weight = _safe_float(profile_config.get("micro_trend_weight"))
  635. if micro_weight is None:
  636. micro_weight = 0.8
  637. meso_weight = _safe_float(profile_config.get("meso_trend_weight"))
  638. if meso_weight is None:
  639. meso_weight = 1.0
  640. cooling_threshold = _safe_float(profile_config.get("trend_cooling_threshold"))
  641. if cooling_threshold is None:
  642. cooling_threshold = 0.45
  643. bullish_inventory_pressure = inventory_state in {"base_heavy", "critically_unbalanced", "depleted_quote_side"}
  644. bearish_inventory_pressure = inventory_state in {"quote_heavy", "critically_unbalanced", "depleted_base_side"}
  645. bullish_cooling_score = 0.0
  646. if meso_structure == "trend_continuation" and meso_bias == "bullish":
  647. bullish_cooling_score += 0.15 * meso_weight
  648. if micro_impulse == "mixed":
  649. bullish_cooling_score += 0.15 * micro_weight
  650. if early_reversal_warning:
  651. bullish_cooling_score += 0.25 * micro_weight
  652. if short_term_warning:
  653. bullish_cooling_score += 0.32 * micro_weight
  654. if micro_bias == "bearish":
  655. bullish_cooling_score += 0.15 * micro_weight
  656. if micro_location in {"near_upper_band", "upper_half", "centered"}:
  657. bullish_cooling_score += 0.15 * micro_weight
  658. bearish_cooling_score = 0.0
  659. if meso_structure == "trend_continuation" and meso_bias == "bearish":
  660. bearish_cooling_score += 0.15 * meso_weight
  661. if micro_impulse == "mixed":
  662. bearish_cooling_score += 0.15 * micro_weight
  663. if early_reversal_warning:
  664. bearish_cooling_score += 0.25 * micro_weight
  665. if short_term_warning:
  666. bearish_cooling_score += 0.32 * micro_weight
  667. if micro_bias == "bullish":
  668. bearish_cooling_score += 0.15 * micro_weight
  669. if micro_location in {"near_lower_band", "lower_half", "centered"}:
  670. bearish_cooling_score += 0.15 * micro_weight
  671. bullish_cooling = (
  672. meso_structure == "trend_continuation"
  673. and meso_bias == "bullish"
  674. and (micro_impulse == "mixed" or early_reversal_warning or short_term_warning)
  675. and micro_bias in {"mixed", "bearish", "bullish"}
  676. and (short_term_warning or micro_location in {"near_upper_band", "upper_half", "centered"})
  677. and bullish_cooling_score >= cooling_threshold
  678. )
  679. bearish_cooling = (
  680. meso_structure == "trend_continuation"
  681. and meso_bias == "bearish"
  682. and (micro_impulse == "mixed" or early_reversal_warning or short_term_warning)
  683. and micro_bias in {"mixed", "bullish", "bearish"}
  684. and (short_term_warning or micro_location in {"near_lower_band", "lower_half", "centered"})
  685. and bearish_cooling_score >= cooling_threshold
  686. )
  687. return bullish_cooling or bearish_cooling
  688. def _grid_fill_proximity(strategy: dict[str, Any], narrative_payload: dict[str, Any]) -> dict[str, Any]:
  689. state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
  690. orders = state.get("orders") if isinstance(state.get("orders"), list) else []
  691. features = narrative_payload.get("features_by_timeframe") if isinstance(narrative_payload.get("features_by_timeframe"), dict) else {}
  692. micro_raw = features.get("1m", {}).get("raw", {}) if isinstance(features.get("1m"), dict) else {}
  693. current_price = _safe_float(micro_raw.get("price") or state.get("last_price") or state.get("center_price"))
  694. atr_percent = _safe_float(micro_raw.get("atr_percent")) or 0.0
  695. if not current_price or current_price <= 0:
  696. return {"near_fill": False}
  697. sell_prices: list[float] = []
  698. buy_prices: list[float] = []
  699. for order in orders:
  700. if not isinstance(order, dict):
  701. continue
  702. if str(order.get("status") or "open").lower() not in {"open", "live", "active"}:
  703. continue
  704. price = _safe_float(order.get("price"))
  705. if price is None or price <= 0:
  706. continue
  707. side = str(order.get("side") or "").lower()
  708. if side == "sell" and price >= current_price:
  709. sell_prices.append(price)
  710. elif side == "buy" and price <= current_price:
  711. buy_prices.append(price)
  712. next_sell = min(sell_prices) if sell_prices else None
  713. next_buy = max(buy_prices) if buy_prices else None
  714. next_sell_distance_pct = (((next_sell - current_price) / current_price) * 100.0) if next_sell else None
  715. next_buy_distance_pct = (((current_price - next_buy) / current_price) * 100.0) if next_buy else None
  716. threshold_pct = max(0.25, atr_percent * 1.5)
  717. near_sell_fill = bool(
  718. next_sell_distance_pct is not None
  719. and next_sell_distance_pct >= 0
  720. and next_sell_distance_pct <= threshold_pct
  721. and next_buy is not None
  722. )
  723. near_buy_fill = bool(
  724. next_buy_distance_pct is not None
  725. and next_buy_distance_pct >= 0
  726. and next_buy_distance_pct <= threshold_pct
  727. and next_sell is not None
  728. )
  729. near_fill_side: str | None = None
  730. if near_sell_fill and near_buy_fill:
  731. near_fill_side = "sell" if (next_sell_distance_pct or 0.0) <= (next_buy_distance_pct or 0.0) else "buy"
  732. elif near_sell_fill:
  733. near_fill_side = "sell"
  734. elif near_buy_fill:
  735. near_fill_side = "buy"
  736. return {
  737. "near_fill": bool(near_sell_fill or near_buy_fill),
  738. "near_fill_side": near_fill_side,
  739. "near_sell_fill": near_sell_fill,
  740. "near_buy_fill": near_buy_fill,
  741. "current_price": current_price,
  742. "next_sell": next_sell,
  743. "next_buy": next_buy,
  744. "next_sell_distance_pct": round(next_sell_distance_pct, 4) if next_sell_distance_pct is not None else None,
  745. "next_buy_distance_pct": round(next_buy_distance_pct, 4) if next_buy_distance_pct is not None else None,
  746. "threshold_pct": round(threshold_pct, 4),
  747. }
  748. def _grid_fill_fights_breakout(grid_fill: dict[str, Any], breakout: dict[str, Any]) -> bool:
  749. """Whether a nearby grid fill is trading against the breakout move.
  750. Current product requirement: grid proximity-to-fills should not block or trigger a handoff.
  751. We only care about overall regime/tradeoff (fees vs staying), not which side happens to fill.
  752. """
  753. return False
  754. def _recent_1m_price_trace(history_window: dict[str, Any] | None) -> list[tuple[datetime, float]]:
  755. recent_states = history_window.get("recent_states") if isinstance(history_window, dict) and isinstance(history_window.get("recent_states"), list) else []
  756. trace: list[tuple[datetime, float]] = []
  757. for row in recent_states:
  758. if not isinstance(row, dict):
  759. continue
  760. try:
  761. payload = json.loads(row.get("payload_json") or "{}")
  762. except Exception:
  763. continue
  764. features = payload.get("features_by_timeframe") if isinstance(payload.get("features_by_timeframe"), dict) else {}
  765. micro = features.get("1m") if isinstance(features.get("1m"), dict) else {}
  766. raw = micro.get("raw") if isinstance(micro.get("raw"), dict) else {}
  767. price = _safe_float(raw.get("price"))
  768. if price is None:
  769. continue
  770. timestamp = _parse_timestamp(row.get("created_at") or payload.get("generated_at"))
  771. if timestamp is None:
  772. continue
  773. trace.append((timestamp, price))
  774. trace.sort(key=lambda item: item[0])
  775. return trace
  776. def _breakout_direction(breakout: dict[str, Any], stance: str | None = None) -> str | None:
  777. meso_bias = str(breakout.get("meso_bias") or "")
  778. micro_bias = str(breakout.get("micro_bias") or "")
  779. if meso_bias in {"bullish", "bearish"}:
  780. return meso_bias
  781. if micro_bias in {"bullish", "bearish"}:
  782. return micro_bias
  783. stance_text = str(stance or "")
  784. if "bullish" in stance_text:
  785. return "bullish"
  786. if "bearish" in stance_text:
  787. return "bearish"
  788. return None
  789. def _narrative_direction(narrative: dict[str, Any]) -> str | None:
  790. stance = str(narrative.get("stance") or "")
  791. breakout = narrative.get("grid_breakout_pressure") if isinstance(narrative.get("grid_breakout_pressure"), dict) else {}
  792. direction = _breakout_direction(breakout, stance)
  793. if direction:
  794. return direction
  795. if stance in {"constructive_bullish", "cautious_bullish", "fragile_bullish"}:
  796. return "bullish"
  797. if stance in {"constructive_bearish", "cautious_bearish", "fragile_bearish"}:
  798. return "bearish"
  799. return None
  800. def _direction_label_from_score(score: float, bullish_threshold: float = 0.18) -> str:
  801. if score >= bullish_threshold:
  802. return "bullish"
  803. if score <= -bullish_threshold:
  804. return "bearish"
  805. return "mixed"
  806. def _extract_decision_signals(*,
  807. narrative_payload: dict[str, Any],
  808. wallet_state: dict[str, Any],
  809. grid_strategy: dict[str, Any] | None = None,
  810. breakout: dict[str, Any] | None = None,
  811. history_window: dict[str, Any] | None = None,
  812. decision_profile: dict[str, Any] | None = None,
  813. ) -> dict[str, Any]:
  814. scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
  815. cross = narrative_payload.get("cross_scope_summary") if isinstance(narrative_payload.get("cross_scope_summary"), dict) else {}
  816. features = narrative_payload.get("features_by_timeframe") if isinstance(narrative_payload.get("features_by_timeframe"), dict) else {}
  817. opportunity_map = narrative_payload.get("opportunity_map") if isinstance(narrative_payload.get("opportunity_map"), dict) else {}
  818. embedded = narrative_payload.get("decision_inputs") if isinstance(narrative_payload.get("decision_inputs"), dict) else {}
  819. micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
  820. meso = scoped.get("meso") if isinstance(scoped.get("meso"), dict) else {}
  821. macro = scoped.get("macro") if isinstance(scoped.get("macro"), dict) else {}
  822. micro_features = features.get("1m") if isinstance(features.get("1m"), dict) else {}
  823. micro_vol = micro_features.get("volatility") if isinstance(micro_features.get("volatility"), dict) else {}
  824. micro_raw = micro_features.get("raw") if isinstance(micro_features.get("raw"), dict) else {}
  825. recent_prices = _recent_1m_price_trace(history_window)
  826. continuation = float(opportunity_map.get("continuation") or 0.0)
  827. alignment = str(cross.get("alignment") or "partial_alignment")
  828. friction = str(cross.get("friction") or "medium")
  829. micro_impulse = str(micro.get("impulse") or "mixed")
  830. micro_bias = str(micro.get("trend_bias") or "mixed")
  831. micro_location = str(micro.get("location") or embedded.get("micro_location") or "unknown")
  832. micro_reversal_risk = str(micro.get("reversal_risk") or "low")
  833. meso_structure = str(meso.get("structure") or "rotation")
  834. meso_bias = str(meso.get("momentum_bias") or "neutral")
  835. macro_bias = str(macro.get("bias") or "mixed")
  836. profile_config = _decision_profile_config(decision_profile)
  837. short_term_trend_min_score = _safe_float(profile_config.get("short_term_trend_min_score"))
  838. if short_term_trend_min_score is None:
  839. short_term_trend_min_score = _safe_float(profile_config.get("short_term_confirmation_min"))
  840. if short_term_trend_min_score is None:
  841. short_term_trend_min_score = 0.32
  842. breakout_persistence_min = _safe_float(profile_config.get("breakout_persistence_min"))
  843. if breakout_persistence_min is None:
  844. breakout_persistence_min = 0.65
  845. trend_hold_threshold = _safe_float(profile_config.get("trend_hold_threshold"))
  846. if trend_hold_threshold is None:
  847. trend_hold_threshold = 0.56
  848. grid_release_threshold = _safe_float(profile_config.get("grid_release_threshold"))
  849. if grid_release_threshold is None:
  850. grid_release_threshold = 0.35
  851. structural_direction = str(embedded.get("structural_direction") or "")
  852. if structural_direction not in {"bullish", "bearish"}:
  853. structural_direction = meso_bias if meso_bias in {"bullish", "bearish"} else macro_bias if macro_bias in {"bullish", "bearish"} else "mixed"
  854. structural_strength = _safe_float(embedded.get("structural_trend_strength"))
  855. if structural_strength is None:
  856. structural_strength = 0.0
  857. if meso_structure == "trend_continuation" and meso_bias in {"bullish", "bearish"}:
  858. structural_strength += 0.45
  859. elif meso_structure in {"bullish_pullback", "bearish_pullback"} and meso_bias in {"bullish", "bearish"}:
  860. structural_strength += 0.25
  861. if macro_bias in {"bullish", "bearish"} and macro_bias == structural_direction:
  862. structural_strength += 0.25
  863. if alignment == "micro_meso_macro_aligned":
  864. structural_strength += 0.2
  865. elif alignment == "partial_alignment":
  866. structural_strength += 0.1
  867. if friction == "high":
  868. structural_strength -= 0.18
  869. structural_strength = round(_clamp(structural_strength, 0.0, 1.0), 4)
  870. tactical_direction = str(embedded.get("tactical_direction") or "")
  871. if tactical_direction not in {"bullish", "bearish", "mixed"}:
  872. micro_score = 0.0
  873. if micro_impulse == "up":
  874. micro_score += 0.35
  875. elif micro_impulse == "down":
  876. micro_score -= 0.35
  877. if micro_bias == "bullish":
  878. micro_score += 0.45
  879. elif micro_bias == "bearish":
  880. micro_score -= 0.45
  881. tactical_direction = _direction_label_from_score(micro_score)
  882. tactical_strength = _safe_float(embedded.get("tactical_trend_strength"))
  883. if tactical_strength is None:
  884. tactical_strength = 0.0
  885. if micro_impulse in {"up", "down"} and micro_bias in {"bullish", "bearish"}:
  886. tactical_strength += 0.45
  887. elif micro_impulse in {"up", "down"}:
  888. tactical_strength += 0.2
  889. if micro_location in {"near_upper_band", "near_lower_band"}:
  890. tactical_strength += 0.1
  891. if micro_reversal_risk == "medium":
  892. tactical_strength -= 0.12
  893. elif micro_reversal_risk == "high":
  894. tactical_strength -= 0.25
  895. tactical_strength = round(_clamp(tactical_strength, 0.0, 1.0), 4)
  896. tactical_range_quality = _safe_float(embedded.get("tactical_range_quality"))
  897. if tactical_range_quality is None:
  898. tactical_range_quality = 0.0
  899. if micro_impulse == "mixed":
  900. tactical_range_quality += 0.35
  901. if micro_bias == "mixed":
  902. tactical_range_quality += 0.2
  903. if micro_location in {"centered", "lower_half", "upper_half"}:
  904. tactical_range_quality += 0.18
  905. if friction == "high":
  906. tactical_range_quality += 0.08
  907. if micro_reversal_risk == "high":
  908. tactical_range_quality -= 0.08
  909. tactical_range_quality = round(_clamp(tactical_range_quality, 0.0, 1.0), 4)
  910. tactical_easing = bool(embedded.get("tactical_easing"))
  911. if not tactical_easing:
  912. tactical_easing = bool(
  913. meso_structure == "trend_continuation"
  914. and meso_bias in {"bullish", "bearish"}
  915. and (
  916. micro_impulse == "mixed"
  917. or micro_bias == "mixed"
  918. or micro_reversal_risk in {"medium", "high"}
  919. or micro_location == "centered"
  920. )
  921. )
  922. breakout = breakout or {}
  923. breakout_phase = str(breakout.get("phase") or "none")
  924. breakout_persistence = 1.0 if bool(breakout.get("persistent")) else 0.65 if breakout_phase == "developing" else 0.35 if breakout_phase == "probing" else 0.0
  925. grid_step_pct = None
  926. if grid_strategy:
  927. state = grid_strategy.get("state") if isinstance(grid_strategy.get("state"), dict) else {}
  928. config = grid_strategy.get("config") if isinstance(grid_strategy.get("config"), dict) else {}
  929. grid_step_pct = _safe_float(config.get("grid_step_pct") or state.get("grid_step_pct") or state.get("recenter_pct_live"))
  930. atr_percent = _safe_float(embedded.get("micro_atr_percent"))
  931. if atr_percent is None:
  932. atr_percent = _safe_float(micro_raw.get("atr_percent"))
  933. band_width_pct = _safe_float(embedded.get("micro_bollinger_width_pct"))
  934. if band_width_pct is None:
  935. band_width_pct = _safe_float(micro_vol.get("bollinger_width_pct"))
  936. noise_pct = max(band_width_pct or 0.0, (atr_percent or 0.0) * 2.0)
  937. pullback_to_grid_ratio = None
  938. if grid_step_pct and grid_step_pct > 0:
  939. pullback_to_grid_ratio = noise_pct / max(grid_step_pct * 100.0, 0.0001)
  940. recent_move_pct = 0.0
  941. recent_move_window_minutes = 0
  942. recent_move_direction = "mixed"
  943. if recent_prices:
  944. current_price = _safe_float(micro_raw.get("price")) or recent_prices[-1][1]
  945. first_price = recent_prices[0][1]
  946. if first_price > 0:
  947. recent_move_pct = ((current_price - first_price) / first_price) * 100.0
  948. recent_move_window_minutes = max(0, int((recent_prices[-1][0] - recent_prices[0][0]).total_seconds() / 60.0))
  949. if recent_move_pct > 0:
  950. recent_move_direction = "bullish"
  951. elif recent_move_pct < 0:
  952. recent_move_direction = "bearish"
  953. rapid_directional_pressure = bool(
  954. recent_move_direction in {"bullish", "bearish"}
  955. and abs(recent_move_pct) >= max(0.8, (atr_percent or 0.0) * 2.5)
  956. and recent_move_window_minutes >= 10
  957. and structural_direction == recent_move_direction
  958. and tactical_direction == recent_move_direction
  959. and macro_bias == recent_move_direction
  960. )
  961. if breakout and isinstance(breakout, dict):
  962. rapid_directional_pressure = bool(
  963. rapid_directional_pressure
  964. or (
  965. breakout.get("persistent")
  966. and str(breakout.get("macro_bias") or "") == recent_move_direction
  967. and str(breakout.get("meso_bias") or "") == recent_move_direction
  968. and str(breakout.get("micro_bias") or "") == recent_move_direction
  969. and abs(recent_move_pct) >= max(0.6, (atr_percent or 0.0) * 1.8)
  970. )
  971. )
  972. rapid_downside_pressure = bool(rapid_directional_pressure and recent_move_direction == "bearish")
  973. short_term_trend_score = _short_term_trend_manifest_score(narrative_payload, structural_direction)
  974. harvestability_score = tactical_range_quality * 0.45
  975. if pullback_to_grid_ratio is not None:
  976. harvestability_score += min(pullback_to_grid_ratio, 2.0) * 0.22
  977. elif atr_percent is not None:
  978. harvestability_score += min((atr_percent or 0.0) / 0.5, 1.0) * 0.18
  979. if tactical_easing:
  980. harvestability_score += 0.18
  981. if micro_location in {"centered", "lower_half", "upper_half"}:
  982. harvestability_score += 0.08
  983. if breakout_persistence >= 1.0 and not tactical_easing and tactical_strength >= 0.5:
  984. harvestability_score -= 0.3
  985. harvestability_score = round(_clamp(harvestability_score, 0.0, 1.0), 4)
  986. inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
  987. within_rebalance_tolerance = _wallet_within_rebalance_tolerance(wallet_state, 0.3)
  988. if wallet_state.get("grid_ready"):
  989. wallet_grid_usability = 1.0
  990. elif within_rebalance_tolerance:
  991. wallet_grid_usability = 0.78
  992. elif inventory_state in {"base_heavy", "quote_heavy"}:
  993. wallet_grid_usability = 0.42
  994. elif inventory_state in SEVERE_INVENTORY_STATES:
  995. wallet_grid_usability = 0.12
  996. else:
  997. wallet_grid_usability = 0.3
  998. if scoped:
  999. trend_hold_strength = (
  1000. structural_strength * 0.34
  1001. + tactical_strength * 0.24
  1002. + breakout_persistence * 0.14
  1003. + min(short_term_trend_score, 1.0) * 0.10
  1004. + continuation * 0.18
  1005. )
  1006. else:
  1007. trend_hold_strength = continuation * 0.9 + breakout_persistence * 0.1
  1008. if tactical_easing:
  1009. trend_hold_strength -= 0.18
  1010. if tactical_direction not in {"mixed", structural_direction}:
  1011. trend_hold_strength -= 0.16
  1012. if short_term_trend_score < short_term_trend_min_score:
  1013. trend_hold_strength -= min(short_term_trend_min_score - short_term_trend_score, 0.25)
  1014. trend_hold_strength = round(_clamp(trend_hold_strength, 0.0, 1.0), 4)
  1015. trend_following_pressure = bool(
  1016. (
  1017. structural_direction in {"bullish", "bearish"}
  1018. and tactical_direction == structural_direction
  1019. and breakout_persistence >= breakout_persistence_min
  1020. and trend_hold_strength >= trend_hold_threshold
  1021. )
  1022. or (
  1023. not scoped
  1024. and continuation >= 0.7
  1025. and not tactical_easing
  1026. )
  1027. )
  1028. grid_harvestable_now = bool(
  1029. harvestability_score >= 0.48
  1030. and wallet_grid_usability >= 0.35
  1031. )
  1032. rebalancer_release_ready = bool(
  1033. within_rebalance_tolerance
  1034. and (
  1035. (
  1036. harvestability_score >= 0.35
  1037. and (tactical_easing or breakout_persistence < 1.0 or tactical_range_quality >= 0.35)
  1038. )
  1039. or (wallet_state.get("grid_ready") and breakout_persistence < 1.0)
  1040. or (tactical_range_quality >= grid_release_threshold and breakout_persistence < 0.75)
  1041. )
  1042. )
  1043. return {
  1044. "structural_direction": structural_direction,
  1045. "structural_trend_strength": structural_strength,
  1046. "tactical_direction": tactical_direction,
  1047. "tactical_trend_strength": tactical_strength,
  1048. "tactical_range_quality": tactical_range_quality,
  1049. "tactical_easing": tactical_easing,
  1050. "breakout_persistence_score": round(breakout_persistence, 4),
  1051. "micro_location": micro_location,
  1052. "micro_atr_percent": atr_percent,
  1053. "micro_bollinger_width_pct": band_width_pct,
  1054. "grid_step_pct": round(grid_step_pct, 6) if grid_step_pct is not None else None,
  1055. "pullback_to_grid_ratio": round(pullback_to_grid_ratio, 4) if pullback_to_grid_ratio is not None else None,
  1056. "grid_harvestability_score": harvestability_score,
  1057. "wallet_grid_usability": round(wallet_grid_usability, 4),
  1058. "within_rebalance_tolerance": within_rebalance_tolerance,
  1059. "rebalance_tolerance": 0.3,
  1060. "trend_following_pressure": trend_following_pressure,
  1061. "trend_hold_strength": trend_hold_strength,
  1062. "trend_hold_threshold": round(trend_hold_threshold, 4),
  1063. "rapid_directional_pressure": rapid_directional_pressure,
  1064. "rapid_downside_pressure": rapid_downside_pressure,
  1065. "recent_move_pct": round(recent_move_pct, 4),
  1066. "recent_move_window_minutes": recent_move_window_minutes,
  1067. "short_term_trend_min_score": round(short_term_trend_min_score, 4),
  1068. "breakout_persistence_min": round(breakout_persistence_min, 4),
  1069. "grid_release_threshold": round(grid_release_threshold, 4),
  1070. "short_term_trend_score": short_term_trend_score,
  1071. "grid_harvestable_now": grid_harvestable_now,
  1072. "rebalancer_release_ready": rebalancer_release_ready,
  1073. }
  1074. def _strategy_trade_side(strategy: dict[str, Any]) -> str:
  1075. config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
  1076. state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
  1077. side = str(config.get("trade_side") or state.get("trade_side") or strategy.get("trade_side") or "both").strip().lower()
  1078. return side if side in {"buy", "sell", "both"} else "both"
  1079. def _trend_handoff_level_threshold(breakout: dict[str, Any]) -> float:
  1080. memory = breakout.get("time_window_memory") if isinstance(breakout.get("time_window_memory"), dict) else {}
  1081. if bool(memory.get("promoted_to_confirmed")):
  1082. return 2.0
  1083. return 2.75
  1084. def _grid_switch_tradeoff(*,
  1085. current_primary: dict[str, Any],
  1086. wallet_state: dict[str, Any],
  1087. breakout: dict[str, Any],
  1088. grid_fill: dict[str, Any],
  1089. grid_pressure: dict[str, Any],
  1090. directional_micro_clear: bool,
  1091. decision_signals: dict[str, Any],
  1092. trend: dict[str, Any] | None,
  1093. ) -> dict[str, Any]:
  1094. inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
  1095. supervision = current_primary.get("supervision") if isinstance(current_primary.get("supervision"), dict) else {}
  1096. open_order_count = int(current_primary.get("open_order_count") or 0)
  1097. if not open_order_count:
  1098. state = current_primary.get("state") if isinstance(current_primary.get("state"), dict) else {}
  1099. open_order_count = int(state.get("open_order_count") or len(state.get("orders") or []) or 0)
  1100. adverse_side = str(supervision.get("adverse_side") or "unknown")
  1101. adverse_count = int(supervision.get("adverse_side_open_order_count") or 0)
  1102. adverse_notional = float(supervision.get("adverse_side_open_order_notional_quote") or 0.0)
  1103. adverse_distance = _safe_float(supervision.get("adverse_side_nearest_distance_pct"))
  1104. base_order_notional = 1.0
  1105. config = current_primary.get("config") if isinstance(current_primary.get("config"), dict) else {}
  1106. for candidate in (config.get("order_notional_quote"), config.get("max_order_notional_quote")):
  1107. candidate_value = _safe_float(candidate)
  1108. if candidate_value and candidate_value > base_order_notional:
  1109. base_order_notional = candidate_value
  1110. trend_score = float(trend.get("score") or 0.0) if trend else 0.0
  1111. structural_strength = float(decision_signals.get("structural_trend_strength") or 0.0)
  1112. tactical_strength = float(decision_signals.get("tactical_trend_strength") or 0.0)
  1113. harvestability_score = float(decision_signals.get("grid_harvestability_score") or 0.0)
  1114. breakout_score = float(breakout.get("score") or 0.0)
  1115. short_term_trend_score = float(decision_signals.get("short_term_trend_score") or 0.0)
  1116. levels = float(grid_pressure.get("levels") or 0.0)
  1117. near_fill = bool(grid_fill.get("near_fill"))
  1118. fill_fights = _grid_fill_fights_breakout(grid_fill, breakout)
  1119. persistent = bool(breakout.get("persistent"))
  1120. trend_ready = bool(decision_signals.get("trend_following_pressure")) and directional_micro_clear
  1121. stay_cost = 0.0
  1122. switch_benefit = 0.0
  1123. if persistent:
  1124. switch_benefit += 0.28
  1125. if trend_ready:
  1126. switch_benefit += 0.34
  1127. # Requirement: ignore nearby fill timing/side when estimating the stay-vs-switch tradeoff.
  1128. if levels >= _trend_handoff_level_threshold(breakout):
  1129. switch_benefit += 0.18
  1130. switch_benefit += structural_strength * 0.26
  1131. switch_benefit += tactical_strength * 0.16
  1132. switch_benefit += min(trend_score, 2.0) * 0.04
  1133. switch_benefit += min(breakout_score, 5.0) * 0.04
  1134. if short_term_trend_score < 0.68:
  1135. short_term_gap = 0.68 - short_term_trend_score
  1136. switch_benefit -= short_term_gap * 1.15
  1137. stay_cost += short_term_gap * 0.42
  1138. if adverse_side in {"buy", "sell"} and adverse_count > 0:
  1139. adverse_notional_ratio = adverse_notional / max(base_order_notional, 1.0)
  1140. switch_benefit += min(adverse_count, 8) * 0.02
  1141. if adverse_distance is not None and adverse_distance <= 1.25:
  1142. switch_benefit += 0.08
  1143. stay_cost += min(adverse_notional_ratio, 4.0) * 0.07
  1144. else:
  1145. adverse_notional_ratio = 0.0
  1146. if inventory_state == "balanced":
  1147. stay_cost += 0.06
  1148. elif inventory_state in {"base_heavy", "quote_heavy"}:
  1149. stay_cost += 0.16
  1150. elif inventory_state in SEVERE_INVENTORY_STATES:
  1151. stay_cost += 0.28
  1152. else:
  1153. stay_cost += 0.1
  1154. stay_cost += min(levels, 6.0) * 0.06
  1155. stay_cost += min(open_order_count, 8) * 0.025
  1156. # Requirement: ignore nearby fill timing/side when estimating the stay-vs-switch tradeoff.
  1157. if not persistent:
  1158. stay_cost += 0.12
  1159. if adverse_notional_ratio >= 1.0:
  1160. stay_cost += 0.08
  1161. stay_cost += harvestability_score * 0.18
  1162. margin = round(switch_benefit - stay_cost, 4)
  1163. should_switch = persistent and trend_ready and margin > 0.0
  1164. return {
  1165. "trend_score": round(trend_score, 4),
  1166. "structural_trend_strength": round(structural_strength, 4),
  1167. "tactical_trend_strength": round(tactical_strength, 4),
  1168. "grid_harvestability_score": round(harvestability_score, 4),
  1169. "short_term_trend_score": round(short_term_trend_score, 4),
  1170. "breakout_score": round(breakout_score, 4),
  1171. "switch_benefit": round(switch_benefit, 4),
  1172. "stay_cost": round(stay_cost, 4),
  1173. "margin": margin,
  1174. "should_switch": should_switch,
  1175. "trend_ready": trend_ready,
  1176. "persistent": persistent,
  1177. "levels": round(levels, 4),
  1178. "open_order_count": open_order_count,
  1179. "near_fill": near_fill,
  1180. "fill_fights": fill_fights,
  1181. "adverse_side": adverse_side,
  1182. "adverse_side_open_order_count": adverse_count,
  1183. "adverse_side_open_order_notional_quote": round(adverse_notional, 4),
  1184. "adverse_side_nearest_distance_pct": round(adverse_distance, 4) if adverse_distance is not None else None,
  1185. "inventory_state": inventory_state,
  1186. }
  1187. def _grid_trend_pressure(strategy: dict[str, Any], narrative_payload: dict[str, Any]) -> dict[str, Any]:
  1188. state = strategy.get("state") if isinstance(strategy.get("state"), dict) else {}
  1189. config = strategy.get("config") if isinstance(strategy.get("config"), dict) else {}
  1190. features = narrative_payload.get("features_by_timeframe") if isinstance(narrative_payload.get("features_by_timeframe"), dict) else {}
  1191. micro_raw = features.get("1m", {}).get("raw", {}) if isinstance(features.get("1m"), dict) else {}
  1192. current_price = _safe_float(micro_raw.get("price") or state.get("last_price") or state.get("center_price"))
  1193. center_price = _safe_float(state.get("center_price") or state.get("last_price"))
  1194. step_pct = _safe_float(config.get("grid_step_pct") or state.get("grid_step_pct") or state.get("recenter_pct_live")) or 0.0
  1195. if not current_price or not center_price or current_price <= 0 or center_price <= 0 or step_pct <= 0:
  1196. return {"levels": 0.0, "rounded_levels": 0, "direction": "unknown", "current_price": current_price, "center_price": center_price, "step_pct": step_pct}
  1197. distance_pct = abs(current_price - center_price) / center_price
  1198. levels = distance_pct / step_pct
  1199. direction = "bullish" if current_price > center_price else "bearish" if current_price < center_price else "flat"
  1200. return {
  1201. "levels": round(levels, 4),
  1202. "rounded_levels": int(levels),
  1203. "direction": direction,
  1204. "current_price": current_price,
  1205. "center_price": center_price,
  1206. "step_pct": step_pct,
  1207. "distance_pct": round(distance_pct, 4),
  1208. }
  1209. def _grid_can_still_work(strategy: dict[str, Any], wallet_state: dict[str, Any], grid_fill: dict[str, Any]) -> bool:
  1210. supervision = strategy.get("supervision") if isinstance(strategy.get("supervision"), dict) else {}
  1211. side_capacity = supervision.get("side_capacity") if isinstance(supervision.get("side_capacity"), dict) else {}
  1212. buy_capacity = bool(side_capacity.get("buy", False))
  1213. sell_capacity = bool(side_capacity.get("sell", False))
  1214. open_order_count = int(strategy.get("open_order_count") or 0)
  1215. degraded = bool(supervision.get("degraded"))
  1216. inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
  1217. if degraded:
  1218. return False
  1219. if buy_capacity or sell_capacity:
  1220. return True
  1221. if open_order_count > 0:
  1222. return True
  1223. if grid_fill.get("near_fill"):
  1224. return True
  1225. return inventory_state not in SEVERE_INVENTORY_STATES
  1226. def _grid_is_truly_stuck_for_recovery(strategy: dict[str, Any], wallet_state: dict[str, Any], grid_fill: dict[str, Any]) -> bool:
  1227. if _grid_can_still_work(strategy, wallet_state, grid_fill):
  1228. return False
  1229. inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
  1230. return wallet_state.get("rebalance_needed") and inventory_state in SEVERE_INVENTORY_STATES
  1231. def _wallet_within_rebalance_tolerance(wallet_state: dict[str, Any], tolerance: float = 0.3) -> bool:
  1232. imbalance = _safe_float(wallet_state.get("imbalance_score"))
  1233. if imbalance is None:
  1234. base_ratio = _safe_float(wallet_state.get("base_ratio"))
  1235. if base_ratio is not None:
  1236. imbalance = abs(base_ratio - 0.5)
  1237. if imbalance is None:
  1238. return str(wallet_state.get("inventory_state") or "").lower() == "balanced"
  1239. return imbalance <= tolerance
  1240. def _decide_for_grid(*,
  1241. current_primary: dict[str, Any],
  1242. stance: str,
  1243. inventory_state: str,
  1244. wallet_state: dict[str, Any],
  1245. breakout: dict[str, Any],
  1246. grid_fill: dict[str, Any],
  1247. grid_pressure: dict[str, Any],
  1248. directional_micro_clear: bool,
  1249. severe_imbalance: bool,
  1250. decision_signals: dict[str, Any],
  1251. trend: dict[str, Any] | None,
  1252. rebalance: dict[str, Any] | None,
  1253. ) -> tuple[str, str, str | None, list[str], list[str]]:
  1254. action = "keep_grid"
  1255. mode = "observe"
  1256. target_strategy = current_primary["id"]
  1257. reasons: list[str] = []
  1258. blocks: list[str] = []
  1259. inventory_state = _inventory_state_label(inventory_state)
  1260. # Grid is the base mode. Leave it only for a persistent breakout or when
  1261. # the grid has genuinely lost its ability to recover on its own.
  1262. grid_friendly_stance = stance in {"neutral_rotational", "breakout_watch", "cautious_bullish", "cautious_bearish", "fragile_bullish", "fragile_bearish"}
  1263. grid_can_work = _grid_can_still_work(current_primary, wallet_state, grid_fill)
  1264. grid_stuck_for_recovery = _grid_is_truly_stuck_for_recovery(current_primary, wallet_state, grid_fill)
  1265. persistent_breakout = bool(breakout["persistent"])
  1266. breakout_phase = str(breakout.get("phase") or "none")
  1267. breakout_direction = _breakout_direction(breakout, stance)
  1268. trend_handoff_ready = bool(
  1269. trend
  1270. and bool(decision_signals.get("trend_following_pressure"))
  1271. and grid_pressure.get("levels", 0.0) >= _trend_handoff_level_threshold(breakout)
  1272. )
  1273. fill_fights_breakout = _grid_fill_fights_breakout(grid_fill, breakout)
  1274. switch_tradeoff = _grid_switch_tradeoff(
  1275. current_primary=current_primary,
  1276. wallet_state=wallet_state,
  1277. breakout=breakout,
  1278. grid_fill=grid_fill,
  1279. grid_pressure=grid_pressure,
  1280. directional_micro_clear=directional_micro_clear,
  1281. decision_signals=decision_signals,
  1282. trend=trend,
  1283. )
  1284. rapid_directional = bool(decision_signals.get("rapid_directional_pressure"))
  1285. directional_pressure = breakout_direction if breakout_direction in {"bullish", "bearish"} else "mixed"
  1286. all_scopes_aligned = (
  1287. directional_pressure in {"bullish", "bearish"}
  1288. and str(decision_signals.get("structural_direction") or "") == directional_pressure
  1289. and str(decision_signals.get("tactical_direction") or "") == directional_pressure
  1290. and str(grid_pressure.get("direction") or "") == directional_pressure
  1291. )
  1292. repair_inventory_match = bool(
  1293. (directional_pressure == "bullish" and inventory_state in {"quote_heavy", "critically_unbalanced"})
  1294. or (directional_pressure == "bearish" and inventory_state in {"base_heavy", "critically_unbalanced"})
  1295. )
  1296. urgent_rebalance_exit = bool(
  1297. rebalance
  1298. and wallet_state.get("rebalance_needed")
  1299. and rapid_directional
  1300. and all_scopes_aligned
  1301. and repair_inventory_match
  1302. )
  1303. if urgent_rebalance_exit:
  1304. action = "replace_with_exposure_protector"
  1305. target_strategy = rebalance["strategy_id"]
  1306. mode = "act"
  1307. reasons.append("wallet is skewed and the directional move is accelerating, so exposure repair should happen before the trend handoff")
  1308. reasons.append(
  1309. f"recent 1m history moved {decision_signals.get('recent_move_pct', 0.0):.2f}% over about {decision_signals.get('recent_move_window_minutes', 0)} minutes"
  1310. )
  1311. return action, mode, target_strategy, reasons, blocks
  1312. urgent_trend_exit = bool(
  1313. trend
  1314. and persistent_breakout
  1315. and bool(decision_signals.get("trend_following_pressure"))
  1316. and all_scopes_aligned
  1317. and (
  1318. rapid_directional
  1319. or grid_fill.get("near_fill")
  1320. or inventory_state in SEVERE_INVENTORY_STATES
  1321. )
  1322. )
  1323. if urgent_trend_exit:
  1324. action = "replace_with_trend_follower"
  1325. target_strategy = trend["strategy_id"] if trend else target_strategy
  1326. mode = "act"
  1327. reasons.append("all scopes line up and the tape is moving fast, so grid should yield early")
  1328. if rapid_directional:
  1329. reasons.append(
  1330. f"recent 1m history moved {decision_signals.get('recent_move_pct', 0.0):.2f}% over about {decision_signals.get('recent_move_window_minutes', 0)} minutes"
  1331. )
  1332. if grid_pressure.get("levels", 0.0) < _trend_handoff_level_threshold(breakout):
  1333. reasons.append("handoff is happening early, before the normal level threshold, because directional acceleration is sharp")
  1334. if grid_fill.get("near_fill"):
  1335. reasons.append("grid fill pressure is already near the market")
  1336. return action, mode, target_strategy, reasons, blocks
  1337. if severe_imbalance and persistent_breakout:
  1338. reasons.append("grid imbalance now coincides with persistent breakout pressure")
  1339. directional_inventory = _inventory_breakout_is_directionally_compatible(inventory_state, breakout)
  1340. if switch_tradeoff["should_switch"] and trend_handoff_ready and (
  1341. not wallet_state.get("rebalance_needed")
  1342. or directional_inventory
  1343. or not rebalance
  1344. or trend["score"] >= rebalance["score"]
  1345. ):
  1346. action = "replace_with_trend_follower"
  1347. target_strategy = trend["strategy_id"]
  1348. mode = "act"
  1349. if switch_tradeoff.get("adverse_side_open_order_count", 0) > 0:
  1350. reasons.append(
  1351. f"{switch_tradeoff.get('adverse_side')} ladder is exposed near market"
  1352. )
  1353. if directional_inventory:
  1354. reasons.append("inventory posture can be absorbed by the directional handoff")
  1355. reasons.append(
  1356. f"switch benefit ({switch_tradeoff['switch_benefit']:.2f}) exceeds stay cost ({switch_tradeoff['stay_cost']:.2f})"
  1357. )
  1358. elif wallet_state.get("rebalance_needed") and rebalance and rebalance["score"] > 0.35:
  1359. action = "replace_with_exposure_protector"
  1360. target_strategy = rebalance["strategy_id"]
  1361. mode = "act"
  1362. else:
  1363. action = "suspend_grid"
  1364. mode = "warn"
  1365. elif severe_imbalance and grid_stuck_for_recovery and not persistent_breakout and rebalance and rebalance["score"] > 0.6:
  1366. action = "replace_with_exposure_protector"
  1367. target_strategy = rebalance["strategy_id"]
  1368. mode = "act"
  1369. reasons.append("grid has lost practical recovery capacity, so inventory repair should take over")
  1370. elif persistent_breakout and trend_handoff_ready and inventory_state in {"balanced", "base_heavy", "quote_heavy"}:
  1371. if not switch_tradeoff["should_switch"]:
  1372. reasons.append(
  1373. 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})"
  1374. )
  1375. if switch_tradeoff.get("adverse_side_open_order_count", 0) > 0:
  1376. reasons.append(
  1377. f"{switch_tradeoff.get('adverse_side')} ladder exposure is not yet costly enough to justify the handoff"
  1378. )
  1379. if grid_fill.get("near_fill") and fill_fights_breakout:
  1380. reasons.append("nearby opposing fill is only a warning here, not enough on its own to justify the handoff")
  1381. else:
  1382. action = "replace_with_trend_follower"
  1383. target_strategy = trend["strategy_id"] if trend else target_strategy
  1384. mode = "act"
  1385. if grid_fill.get("near_fill") and fill_fights_breakout:
  1386. reasons.append("confirmed trend should not be delayed by a nearby grid fill that trades against the move")
  1387. elif grid_fill.get("near_fill"):
  1388. reasons.append("confirmed directional pressure is strong enough that nearby grid fills should not delay the trend handoff")
  1389. else:
  1390. reasons.append("grid should yield because directional pressure is confirmed and the trend handoff is ready")
  1391. elif not persistent_breakout and grid_can_work:
  1392. if breakout_phase == "developing":
  1393. reasons.append("breakout pressure is developing, but grid can still work and should not be abandoned yet")
  1394. else:
  1395. reasons.append("grid can still operate and self-heal, so inventory skew alone should not force a rebalance handoff")
  1396. if decision_signals.get("grid_harvestable_now"):
  1397. reasons.append("tactical range quality still looks harvestable for the grid")
  1398. elif persistent_breakout and grid_fill.get("near_fill") and inventory_state in {"balanced", "base_heavy", "quote_heavy"}:
  1399. reasons.append("grid is still close to a working fill, but trend handoff is not ready enough yet")
  1400. elif not grid_friendly_stance and persistent_breakout:
  1401. reasons.append("grid should yield because directional pressure is persistent across scopes")
  1402. if trend_handoff_ready:
  1403. action = "replace_with_trend_follower"
  1404. target_strategy = trend["strategy_id"]
  1405. mode = "act"
  1406. else:
  1407. mode = "warn"
  1408. if grid_pressure.get("levels", 0.0) < _trend_handoff_level_threshold(breakout):
  1409. blocks.append("grid has not yet been eaten by enough levels to justify leaving it")
  1410. else:
  1411. blocks.append("directional pressure is rising but the micro layer is not clear enough for a trend handoff")
  1412. else:
  1413. reasons.append("grid can likely self-heal because breakout pressure is not yet persistent")
  1414. return action, mode, target_strategy, reasons, blocks
  1415. def _decide_for_trend(*,
  1416. current_primary: dict[str, Any],
  1417. stance: str,
  1418. narrative_payload: dict[str, Any],
  1419. wallet_state: dict[str, Any],
  1420. grid: dict[str, Any] | None,
  1421. rebalance: dict[str, Any] | None = None,
  1422. profile_config: dict[str, Any] | None = None,
  1423. decision_signals: dict[str, Any] | None = None,
  1424. ) -> tuple[str, str, str | None, list[str], list[str]]:
  1425. action = "keep_trend"
  1426. mode = "observe"
  1427. target_strategy = current_primary["id"]
  1428. reasons: list[str] = []
  1429. blocks: list[str] = []
  1430. decision_signals = decision_signals if isinstance(decision_signals, dict) else {}
  1431. trend_pressure = bool(decision_signals.get("trend_following_pressure"))
  1432. trend_hold_strength = float(decision_signals.get("trend_hold_strength") or 0.0)
  1433. trend_hold_threshold = float(decision_signals.get("trend_hold_threshold") or 0.56)
  1434. grid_harvestable_now = bool(decision_signals.get("grid_harvestable_now"))
  1435. # Trend should cool into rebalancing first when the wallet is skewed, then
  1436. # let rebalancer hand back to grid once the inventory is healthy again.
  1437. cooling = _trend_cooling_edge(narrative_payload, wallet_state, profile_config)
  1438. if cooling:
  1439. if wallet_state.get("rebalance_needed") and rebalance:
  1440. action = "replace_with_exposure_protector"
  1441. target_strategy = rebalance["strategy_id"]
  1442. mode = "act"
  1443. reasons.append("trend has cooled enough that directional mode no longer justifies staying active")
  1444. elif grid and wallet_state.get("grid_ready"):
  1445. action = "replace_with_grid"
  1446. target_strategy = grid["strategy_id"]
  1447. mode = "act"
  1448. reasons.append("trend has cooled and the tape looks suitable for grid again")
  1449. else:
  1450. mode = "warn"
  1451. blocks.append("trend is easing, but neither grid nor rebalancer is ready for a clean handoff")
  1452. elif not trend_pressure:
  1453. if grid and wallet_state.get("grid_ready") and grid_harvestable_now:
  1454. action = "replace_with_grid"
  1455. target_strategy = grid["strategy_id"]
  1456. mode = "act"
  1457. reasons.append(f"trend hold strength {trend_hold_strength:.2f} fell below threshold {trend_hold_threshold:.2f}, so grid can resume")
  1458. elif wallet_state.get("rebalance_needed") and rebalance:
  1459. action = "replace_with_exposure_protector"
  1460. target_strategy = rebalance["strategy_id"]
  1461. mode = "act"
  1462. reasons.append(f"trend hold strength {trend_hold_strength:.2f} fell below threshold {trend_hold_threshold:.2f}, so directional mode should yield")
  1463. else:
  1464. action = "hold_trend"
  1465. mode = "warn"
  1466. blocks.append(f"trend hold strength {trend_hold_strength:.2f} is below threshold {trend_hold_threshold:.2f}, but no clean handoff is available yet")
  1467. elif stance == "neutral_rotational":
  1468. if wallet_state.get("rebalance_needed") and rebalance:
  1469. action = "replace_with_exposure_protector"
  1470. target_strategy = rebalance["strategy_id"]
  1471. mode = "act"
  1472. reasons.append("trend conditions have cooled and rebalancing should repair the wallet before grid resumes")
  1473. elif grid and wallet_state.get("grid_ready"):
  1474. action = "replace_with_grid"
  1475. target_strategy = grid["strategy_id"]
  1476. mode = "act"
  1477. reasons.append("trend conditions have cooled and wallet is grid-ready again")
  1478. elif wallet_state.get("rebalance_needed"):
  1479. mode = "warn"
  1480. blocks.append("trend has cooled but rebalancing should be the next hop")
  1481. else:
  1482. action = "hold_trend"
  1483. blocks.append("grid candidate not strong enough yet")
  1484. else:
  1485. reasons.append(f"trend hold strength {trend_hold_strength:.2f} still clears threshold {trend_hold_threshold:.2f}")
  1486. return action, mode, target_strategy, reasons, blocks
  1487. def _decide_for_rebalancer(*,
  1488. current_primary: dict[str, Any],
  1489. stance: str,
  1490. wallet_state: dict[str, Any],
  1491. grid: dict[str, Any] | None,
  1492. decision_signals: dict[str, Any],
  1493. trend: dict[str, Any] | None = None,
  1494. decision_profile: dict[str, Any] | None = None,
  1495. ) -> tuple[str, str, str | None, list[str], list[str]]:
  1496. action = "keep_rebalancer"
  1497. mode = "observe"
  1498. target_strategy = current_primary["id"]
  1499. reasons: list[str] = []
  1500. blocks: list[str] = []
  1501. # Rebalancing is a repair phase. Once the wallet is usable again, Hermes
  1502. # should prefer handing back to grid, not directly to trend.
  1503. trend_strength = float(trend["score"]) if trend and isinstance(trend.get("score"), (int, float)) else 0.0
  1504. within_tolerance = bool(decision_signals.get("within_rebalance_tolerance"))
  1505. release_ready = bool(decision_signals.get("rebalancer_release_ready"))
  1506. trend_pressure = bool(decision_signals.get("trend_following_pressure"))
  1507. grid_harvestable_now = bool(decision_signals.get("grid_harvestable_now"))
  1508. profile_config = _decision_profile_config(decision_profile)
  1509. force_grid_when_balanced = bool(profile_config.get("force_grid_when_balanced", True))
  1510. hold_rebalancer_until_cooldown = bool(profile_config.get("hold_rebalancer_until_cooldown", False))
  1511. if wallet_state.get("grid_ready") and grid and force_grid_when_balanced and not hold_rebalancer_until_cooldown:
  1512. action = "replace_with_grid"
  1513. target_strategy = grid["strategy_id"]
  1514. mode = "act"
  1515. reasons.append("wallet is rebalanced, so grid should resume first and let the tape prove itself again")
  1516. elif trend_pressure and not release_ready:
  1517. blocks.append("trend is still strong enough that rebalancer should keep repairing instead of resetting to grid")
  1518. elif release_ready:
  1519. if grid:
  1520. action = "replace_with_grid"
  1521. target_strategy = grid["strategy_id"]
  1522. mode = "act"
  1523. reasons.append("wallet is usable enough and micro conditions are easing, so grid can resume harvesting")
  1524. else:
  1525. blocks.append("wallet is within the rebalance tolerance but no grid candidate is available")
  1526. elif within_tolerance and not grid_harvestable_now:
  1527. blocks.append("wallet is close enough, but the local tape is still not harvestable enough for grid release")
  1528. elif wallet_state.get("grid_ready") and stance == "neutral_rotational":
  1529. if grid and grid["score"] >= 0.5:
  1530. action = "replace_with_grid"
  1531. target_strategy = grid["strategy_id"]
  1532. mode = "act"
  1533. reasons.append("rebalance is complete and rotational conditions support grid again")
  1534. else:
  1535. blocks.append("wallet is ready but grid fit is still too weak")
  1536. elif grid and grid_harvestable_now:
  1537. action = "replace_with_grid"
  1538. target_strategy = grid["strategy_id"]
  1539. mode = "act"
  1540. reasons.append("local price action looks harvestable enough that grid can resume before perfect balance")
  1541. else:
  1542. blocks.append("trend candidate is not strong enough yet and grid fit is not ready, so rebalancer should not hand directly back to trend")
  1543. return action, mode, target_strategy, reasons, blocks
  1544. 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, decision_profile: dict[str, Any] | None = None) -> DecisionSnapshot:
  1545. concern_account_id = str(concern.get("account_id") or "")
  1546. concern_market_symbol = str(concern.get("market_symbol") or "").strip().lower()
  1547. normalized = [
  1548. normalize_strategy_snapshot(s)
  1549. for s in strategies
  1550. if str(s.get("account_id") or "") == concern_account_id
  1551. and (
  1552. not concern_market_symbol
  1553. or not str(s.get("market_symbol") or "").strip()
  1554. or str(s.get("market_symbol") or "").strip().lower() == concern_market_symbol
  1555. )
  1556. ]
  1557. breakout = _grid_breakout_pressure(narrative_payload, history_window=history_window)
  1558. narrative_for_scoring = {**narrative_payload, "grid_breakout_pressure": breakout}
  1559. fit_reports = [score_strategy_fit(strategy=s, narrative=narrative_for_scoring, wallet_state=wallet_state) for s in normalized]
  1560. ranked = sorted(fit_reports, key=lambda item: item["score"], reverse=True)
  1561. current_primary = _select_current_primary(normalized)
  1562. best = ranked[0] if ranked else None
  1563. stance = str(narrative_payload.get("stance") or "neutral_rotational")
  1564. inventory_state = _inventory_state_label(wallet_state.get("inventory_state"))
  1565. scoped = narrative_payload.get("scoped_state") if isinstance(narrative_payload.get("scoped_state"), dict) else {}
  1566. micro = scoped.get("micro") if isinstance(scoped.get("micro"), dict) else {}
  1567. micro_impulse = str(micro.get("impulse") or "mixed")
  1568. micro_bias = str(micro.get("trend_bias") or "mixed")
  1569. micro_reversal_risk = str(micro.get("reversal_risk") or "low")
  1570. bullish_micro_clear = micro_impulse == "up" and micro_bias == "bullish" and micro_reversal_risk != "high"
  1571. bearish_micro_clear = micro_impulse == "down" and micro_bias == "bearish" and micro_reversal_risk != "high"
  1572. breakout_direction = _breakout_direction(breakout, stance)
  1573. directional_micro_clear = bullish_micro_clear if breakout_direction == "bullish" else bearish_micro_clear if breakout_direction == "bearish" else False
  1574. grid_fill = _grid_fill_proximity(current_primary, narrative_payload) if current_primary and current_primary["strategy_type"] == "grid_trader" else {"near_fill": False}
  1575. 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"}
  1576. severe_imbalance = inventory_state in SEVERE_INVENTORY_STATES
  1577. action = "hold"
  1578. mode = "observe"
  1579. target_strategy = current_primary.get("id") if current_primary else (best.get("strategy_id") if best else None)
  1580. reasons: list[str] = []
  1581. blocks: list[str] = []
  1582. trend = next((r for r in ranked if r["strategy_type"] == "trend_follower"), None)
  1583. rebalance = next((r for r in ranked if r["strategy_type"] == "exposure_protector"), None)
  1584. grid = next((r for r in ranked if r["strategy_type"] == "grid_trader"), None)
  1585. grid_strategy = next((s for s in normalized if s["strategy_type"] == "grid_trader"), None)
  1586. decision_signals = _extract_decision_signals(
  1587. narrative_payload=narrative_payload,
  1588. wallet_state=wallet_state,
  1589. grid_strategy=grid_strategy,
  1590. breakout=breakout,
  1591. history_window=history_window,
  1592. decision_profile=decision_profile,
  1593. )
  1594. switch_tradeoff: dict[str, Any] = {}
  1595. if current_primary and current_primary["strategy_type"] == "grid_trader":
  1596. action, mode, target_strategy, reasons, blocks = _decide_for_grid(
  1597. current_primary=current_primary,
  1598. stance=stance,
  1599. inventory_state=inventory_state,
  1600. wallet_state=wallet_state,
  1601. breakout=breakout,
  1602. grid_fill=grid_fill,
  1603. grid_pressure=grid_pressure,
  1604. directional_micro_clear=directional_micro_clear,
  1605. severe_imbalance=severe_imbalance,
  1606. decision_signals=decision_signals,
  1607. trend=trend,
  1608. rebalance=rebalance,
  1609. )
  1610. switch_tradeoff = _grid_switch_tradeoff(
  1611. current_primary=current_primary,
  1612. wallet_state=wallet_state,
  1613. breakout=breakout,
  1614. grid_fill=grid_fill,
  1615. grid_pressure=grid_pressure,
  1616. directional_micro_clear=directional_micro_clear,
  1617. decision_signals=decision_signals,
  1618. trend=trend,
  1619. )
  1620. elif current_primary and current_primary["strategy_type"] == "trend_follower":
  1621. action, mode, target_strategy, reasons, blocks = _decide_for_trend(
  1622. current_primary=current_primary,
  1623. stance=stance,
  1624. narrative_payload=narrative_payload,
  1625. wallet_state=wallet_state,
  1626. grid=grid,
  1627. rebalance=rebalance,
  1628. profile_config=_decision_profile_config(decision_profile),
  1629. decision_signals=decision_signals,
  1630. )
  1631. elif current_primary and current_primary["strategy_type"] == "exposure_protector":
  1632. action, mode, target_strategy, reasons, blocks = _decide_for_rebalancer(
  1633. current_primary=current_primary,
  1634. stance=stance,
  1635. wallet_state=wallet_state,
  1636. grid=grid,
  1637. decision_signals=decision_signals,
  1638. trend=trend,
  1639. decision_profile=decision_profile,
  1640. )
  1641. else:
  1642. if best and best["score"] >= 0.55:
  1643. action = f"enable_{best['strategy_type']}"
  1644. target_strategy = best["strategy_id"]
  1645. mode = "act"
  1646. reasons.extend(best["reasons"])
  1647. else:
  1648. action = "wait"
  1649. mode = "observe"
  1650. blocks.append("no strategy is yet a strong enough fit")
  1651. profile_config = _decision_profile_config(decision_profile)
  1652. switch_cost_penalty = _safe_float(profile_config.get("switch_cost_penalty"))
  1653. if switch_cost_penalty is None:
  1654. switch_cost_penalty = 1.0
  1655. action_cooldown_seconds = int(_safe_float(profile_config.get("action_cooldown_seconds")) or 0)
  1656. current_score = float(next((r["score"] for r in ranked if current_primary and r["strategy_id"] == current_primary.get("id")), 0.0))
  1657. target_score = float(next((r["score"] for r in ranked if r["strategy_id"] == target_strategy), current_score))
  1658. switch_edge = round(target_score - current_score, 4)
  1659. required_switch_edge = round(max(switch_cost_penalty - 1.0, 0.0) * 0.08, 4)
  1660. cooldown_active, cooldown_remaining, cooldown_action = _recent_switch_cooldown_active(history_window, str(concern.get("id") or ""), action_cooldown_seconds)
  1661. if mode == "act" and current_primary and target_strategy and target_strategy != current_primary.get("id"):
  1662. if required_switch_edge > 0 and switch_edge < required_switch_edge:
  1663. mode = "observe"
  1664. action = f"keep_{current_primary['strategy_type'].replace('_trader', '').replace('_follower', '').replace('exposure_protector', 'rebalancer')}"
  1665. target_strategy = current_primary.get("id")
  1666. reasons = []
  1667. blocks = [f"switch edge {switch_edge:.2f} is below required friction {required_switch_edge:.2f}"]
  1668. elif cooldown_active:
  1669. mode = "observe"
  1670. action = f"keep_{current_primary['strategy_type'].replace('_trader', '').replace('_follower', '').replace('exposure_protector', 'rebalancer')}"
  1671. target_strategy = current_primary.get("id")
  1672. reasons = []
  1673. blocks = [f"switch cooldown active for {cooldown_remaining:.0f}s after {cooldown_action or 'recent switch'}"]
  1674. reason_summary = reasons[0] if reasons else (blocks[0] if blocks else "strategy posture unchanged")
  1675. confidence = float(narrative_payload.get("confidence") or 0.4)
  1676. if action.startswith("replace_with") or action.startswith("enable_"):
  1677. confidence += 0.08
  1678. if wallet_state.get("rebalance_needed") and "grid" in action:
  1679. confidence -= 0.08
  1680. confidence = round(_clamp(confidence, 0.2, 0.95), 3)
  1681. payload = {
  1682. "generated_at": datetime.now(timezone.utc).isoformat(),
  1683. "wallet_state": wallet_state,
  1684. "narrative_stance": stance,
  1685. "strategy_fit_ranking": ranked,
  1686. "current_primary_strategy": current_primary.get("id") if current_primary else None,
  1687. "argus_decision_context": _argus_decision_context(narrative_payload),
  1688. "history_window": history_window or {},
  1689. "grid_breakout_pressure": breakout,
  1690. "grid_fill_context": grid_fill,
  1691. "grid_switch_tradeoff": switch_tradeoff if current_primary and current_primary["strategy_type"] == "grid_trader" else {},
  1692. "decision_audit": decision_signals,
  1693. "switch_friction": {
  1694. "switch_cost_penalty": round(switch_cost_penalty, 4),
  1695. "switch_edge": switch_edge,
  1696. "required_switch_edge": required_switch_edge,
  1697. "action_cooldown_seconds": action_cooldown_seconds,
  1698. "cooldown_active": cooldown_active,
  1699. "cooldown_remaining_seconds": cooldown_remaining,
  1700. },
  1701. "decision_profile": {
  1702. "id": decision_profile.get("id") if isinstance(decision_profile, dict) else None,
  1703. "name": decision_profile.get("name") if isinstance(decision_profile, dict) else None,
  1704. "status": decision_profile.get("status") if isinstance(decision_profile, dict) else None,
  1705. "config": _decision_profile_config(decision_profile),
  1706. } if decision_profile else None,
  1707. "reason_chain": reasons,
  1708. "blocks": blocks,
  1709. "decision_version": 3,
  1710. }
  1711. return DecisionSnapshot(
  1712. mode=mode,
  1713. action=action,
  1714. target_strategy=target_strategy,
  1715. reason_summary=reason_summary,
  1716. confidence=confidence,
  1717. requires_action=mode == "act",
  1718. payload=payload,
  1719. )