test_decision_engine.py 71 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408
  1. from hermes_mcp.decision_engine import assess_wallet_state, make_decision, normalize_strategy_snapshot, score_strategy_fit
  2. def test_assess_wallet_state_marks_one_sided_wallet_as_depleted_base_side():
  3. concern = {"account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  4. account_info = {
  5. "balances": [
  6. {"asset_code": "XRP", "available": 1},
  7. {"asset_code": "USD", "available": 1000},
  8. ]
  9. }
  10. wallet = assess_wallet_state(account_info=account_info, concern=concern, price=2.0)
  11. assert wallet["inventory_state"] == "depleted_base_side"
  12. assert wallet["rebalance_needed"] is True
  13. assert wallet["grid_ready"] is False
  14. def test_assess_wallet_state_infers_base_and_quote_from_market_symbol_when_missing():
  15. concern = {"account_id": "a1", "market_symbol": "xrpusd", "base_currency": None, "quote_currency": None}
  16. account_info = {
  17. "balances": [
  18. {"asset_code": "XRP", "available": 64.68158},
  19. {"asset_code": "USD", "available": 12.64},
  20. ]
  21. }
  22. wallet = assess_wallet_state(account_info=account_info, concern=concern, price=1.318, strategies=[])
  23. assert wallet["base_currency"] == "XRP"
  24. assert wallet["quote_currency"] == "USD"
  25. assert wallet["base_available"] == 64.68158
  26. assert wallet["quote_available"] == 12.64
  27. def test_assess_wallet_state_marks_one_sided_wallet_as_depleted_quote_side():
  28. concern = {"account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  29. account_info = {
  30. "balances": [
  31. {"asset_code": "XRP", "available": 1000},
  32. {"asset_code": "USD", "available": 1},
  33. ]
  34. }
  35. wallet = assess_wallet_state(account_info=account_info, concern=concern, price=2.0)
  36. assert wallet["inventory_state"] == "depleted_quote_side"
  37. assert wallet["rebalance_needed"] is True
  38. assert wallet["grid_ready"] is False
  39. def test_score_strategy_fit_penalizes_grid_when_wallet_unbalanced():
  40. strategy = normalize_strategy_snapshot({
  41. "id": "grid-1",
  42. "strategy_type": "grid_trader",
  43. "mode": "active",
  44. "account_id": "a1",
  45. "state": {},
  46. "config": {},
  47. })
  48. narrative = {"stance": "constructive_bullish", "opportunity_map": {"continuation": 0.7, "mean_reversion": 0.1, "reversal": 0.1, "wait": 0.1}}
  49. wallet_state = {"inventory_state": "critically_unbalanced", "rebalance_needed": True}
  50. fit = score_strategy_fit(strategy=strategy, narrative=narrative, wallet_state=wallet_state)
  51. assert fit["score"] < 0
  52. assert any("grid" in block or "wallet" in block for block in fit["blocks"])
  53. def test_score_strategy_fit_rewards_trend_when_breakout_is_confirmed():
  54. strategy = normalize_strategy_snapshot({
  55. "id": "trend-1",
  56. "strategy_type": "trend_follower",
  57. "mode": "off",
  58. "account_id": "a1",
  59. "state": {},
  60. "config": {},
  61. })
  62. base_narrative = {
  63. "stance": "constructive_bullish",
  64. "opportunity_map": {"continuation": 0.72, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.15},
  65. "grid_breakout_pressure": {"phase": "developing"},
  66. }
  67. confirmed_narrative = {
  68. **base_narrative,
  69. "grid_breakout_pressure": {"phase": "confirmed", "persistent": True},
  70. }
  71. wallet_state = {"inventory_state": "balanced", "rebalance_needed": False}
  72. base_fit = score_strategy_fit(strategy=strategy, narrative=base_narrative, wallet_state=wallet_state)
  73. confirmed_fit = score_strategy_fit(strategy=strategy, narrative=confirmed_narrative, wallet_state=wallet_state)
  74. assert confirmed_fit["score"] > base_fit["score"]
  75. assert any("confirmed breakout" in reason for reason in confirmed_fit["reasons"])
  76. def test_score_strategy_fit_prefers_matching_trade_side():
  77. bullish_narrative = {
  78. "stance": "constructive_bullish",
  79. "opportunity_map": {"continuation": 0.75, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.12},
  80. "grid_breakout_pressure": {"phase": "confirmed", "persistent": True, "micro_bias": "bullish", "meso_bias": "bullish"},
  81. }
  82. bearish_narrative = {
  83. "stance": "constructive_bearish",
  84. "opportunity_map": {"continuation": 0.75, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.12},
  85. "grid_breakout_pressure": {"phase": "confirmed", "persistent": True, "micro_bias": "bearish", "meso_bias": "bearish"},
  86. }
  87. wallet_state = {"inventory_state": "balanced", "rebalance_needed": False}
  88. buy_strategy = normalize_strategy_snapshot({"id": "trend-long", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {"trade_side": "buy"}})
  89. sell_strategy = normalize_strategy_snapshot({"id": "trend-short", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {"trade_side": "sell"}})
  90. buy_fit = score_strategy_fit(strategy=buy_strategy, narrative=bullish_narrative, wallet_state=wallet_state)
  91. sell_fit = score_strategy_fit(strategy=sell_strategy, narrative=bullish_narrative, wallet_state=wallet_state)
  92. assert buy_fit["score"] > sell_fit["score"]
  93. buy_fit_bear = score_strategy_fit(strategy=buy_strategy, narrative=bearish_narrative, wallet_state=wallet_state)
  94. sell_fit_bear = score_strategy_fit(strategy=sell_strategy, narrative=bearish_narrative, wallet_state=wallet_state)
  95. assert sell_fit_bear["score"] > buy_fit_bear["score"]
  96. def test_assess_wallet_state_counts_reserved_orders_in_effective_inventory():
  97. concern = {"account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  98. account_info = {
  99. "balances": [
  100. {"asset_code": "XRP", "available": 0},
  101. {"asset_code": "USD", "available": 0},
  102. ]
  103. }
  104. strategies = [
  105. {
  106. "id": "grid-1",
  107. "strategy_type": "grid_trader",
  108. "mode": "active",
  109. "account_id": "a1",
  110. "market_symbol": "xrpusd",
  111. "state": {
  112. "orders": [
  113. {"side": "sell", "status": "open", "amount": "10", "price": "1.50"},
  114. {"side": "buy", "status": "open", "amount": "10", "price": "1.40"},
  115. ]
  116. },
  117. }
  118. ]
  119. wallet = assess_wallet_state(account_info=account_info, concern=concern, price=1.45, strategies=strategies)
  120. assert wallet["inventory_state"] == "balanced"
  121. assert wallet["base_reserved"] == 10.0
  122. assert wallet["quote_reserved"] == 14.0
  123. assert wallet["base_effective"] == 10.0
  124. assert wallet["quote_effective"] == 14.0
  125. def test_make_decision_keeps_grid_when_imbalance_is_manageable():
  126. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  127. narrative = {
  128. "stance": "constructive_bullish",
  129. "confidence": 0.72,
  130. "opportunity_map": {"continuation": 0.75, "mean_reversion": 0.1, "reversal": 0.05, "wait": 0.1},
  131. }
  132. wallet_state = {
  133. "inventory_state": "base_heavy",
  134. "rebalance_needed": True,
  135. "grid_ready": False,
  136. "base_ratio": 0.8,
  137. "quote_ratio": 0.2,
  138. }
  139. strategies = [
  140. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  141. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  142. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  143. ]
  144. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  145. assert decision.mode == "observe"
  146. assert decision.action == "keep_grid"
  147. assert decision.target_strategy == "grid-1"
  148. def test_make_decision_does_not_replace_grid_with_rebalancer_only_because_grid_mentions_handoff_readiness():
  149. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  150. narrative = {
  151. "stance": "cautious_bullish",
  152. "confidence": 0.74,
  153. "opportunity_map": {"continuation": 0.5, "mean_reversion": 0.25, "reversal": 0.05, "wait": 0.2},
  154. "scoped_state": {
  155. "micro": {"impulse": "mixed", "trend_bias": "mixed"},
  156. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  157. "macro": {"bias": "bullish"},
  158. },
  159. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  160. }
  161. wallet_state = {
  162. "inventory_state": "base_heavy",
  163. "rebalance_needed": True,
  164. "grid_ready": False,
  165. "base_ratio": 0.64,
  166. "quote_ratio": 0.36,
  167. }
  168. strategies = [
  169. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": False, "side_capacity": {"buy": False, "sell": True}}}},
  170. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  171. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  172. ]
  173. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  174. assert decision.action == "keep_grid"
  175. assert decision.target_strategy == "grid-1"
  176. def test_make_decision_replaces_grid_when_breakout_pressure_is_persistent():
  177. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  178. narrative = {
  179. "stance": "constructive_bullish",
  180. "confidence": 0.78,
  181. "opportunity_map": {"continuation": 0.72, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.15},
  182. "scoped_state": {
  183. "micro": {"impulse": "up", "trend_bias": "bullish"},
  184. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  185. "macro": {"bias": "bullish"},
  186. },
  187. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  188. }
  189. wallet_state = {
  190. "inventory_state": "critically_unbalanced",
  191. "rebalance_needed": True,
  192. "grid_ready": False,
  193. "base_ratio": 0.88,
  194. "quote_ratio": 0.12,
  195. }
  196. strategies = [
  197. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  198. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  199. ]
  200. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  201. assert decision.action == "replace_with_exposure_protector"
  202. assert decision.target_strategy == "protect-1"
  203. def test_make_decision_keeps_grid_when_critically_unbalanced_but_grid_still_has_working_side_capacity():
  204. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  205. narrative = {
  206. "stance": "cautious_bullish",
  207. "confidence": 0.7,
  208. "opportunity_map": {"continuation": 0.55, "mean_reversion": 0.2, "reversal": 0.05, "wait": 0.2},
  209. "scoped_state": {
  210. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "upper_half", "reversal_risk": "low"},
  211. "meso": {"structure": "range", "momentum_bias": "bullish"},
  212. "macro": {"bias": "bullish"},
  213. },
  214. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  215. "features_by_timeframe": {"1m": {"raw": {"price": 1.4374}}},
  216. }
  217. wallet_state = {
  218. "inventory_state": "critically_unbalanced",
  219. "rebalance_needed": True,
  220. "grid_ready": False,
  221. "base_ratio": 0.86,
  222. "quote_ratio": 0.14,
  223. }
  224. strategies = [
  225. {
  226. "id": "grid-1",
  227. "strategy_type": "grid_trader",
  228. "mode": "active",
  229. "account_id": "a1",
  230. "state": {
  231. "last_price": 1.4374,
  232. "open_order_count": 4,
  233. "orders": [
  234. {"side": "sell", "status": "open", "price": "1.43956", "amount": "7"},
  235. {"side": "sell", "status": "open", "price": "1.44500", "amount": "7"},
  236. ],
  237. },
  238. "config": {},
  239. "report": {"supervision": {"capacity_available": False, "side_capacity": {"buy": False, "sell": True}, "inventory_pressure": "critical", "degraded": False}},
  240. },
  241. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  242. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  243. ]
  244. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  245. assert decision.mode == "observe"
  246. assert decision.action == "keep_grid"
  247. assert decision.target_strategy == "grid-1"
  248. def test_make_decision_keeps_grid_when_trend_has_only_eaten_two_levels():
  249. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  250. narrative = {
  251. "stance": "constructive_bullish",
  252. "confidence": 0.78,
  253. "opportunity_map": {"continuation": 0.72, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.15},
  254. "scoped_state": {
  255. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "upper_half", "reversal_risk": "low"},
  256. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  257. "macro": {"bias": "bullish"},
  258. },
  259. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  260. "features_by_timeframe": {"1m": {"raw": {"price": 110.0}}},
  261. }
  262. wallet_state = {
  263. "inventory_state": "balanced",
  264. "rebalance_needed": False,
  265. "grid_ready": True,
  266. "base_ratio": 0.52,
  267. "quote_ratio": 0.48,
  268. }
  269. strategies = [
  270. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 100.0}, "config": {"grid_step_pct": 0.05}},
  271. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  272. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  273. ]
  274. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  275. assert decision.mode == "warn"
  276. assert decision.action == "keep_grid"
  277. assert decision.target_strategy == "grid-1"
  278. def test_make_decision_replaces_grid_when_third_level_is_sustained():
  279. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  280. narrative = {
  281. "stance": "constructive_bullish",
  282. "confidence": 0.82,
  283. "opportunity_map": {"continuation": 0.82, "mean_reversion": 0.05, "reversal": 0.03, "wait": 0.1},
  284. "scoped_state": {
  285. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "low"},
  286. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  287. "macro": {"bias": "bullish"},
  288. },
  289. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  290. "features_by_timeframe": {"1m": {"raw": {"price": 116.0}}},
  291. }
  292. wallet_state = {
  293. "inventory_state": "balanced",
  294. "rebalance_needed": False,
  295. "grid_ready": True,
  296. "base_ratio": 0.52,
  297. "quote_ratio": 0.48,
  298. }
  299. strategies = [
  300. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 100.0}, "config": {"grid_step_pct": 0.05}},
  301. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  302. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  303. ]
  304. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  305. assert decision.mode == "act"
  306. assert decision.action == "replace_with_trend_follower"
  307. assert decision.target_strategy == "trend-1"
  308. assert decision.payload["grid_breakout_pressure"]["phase"] == "confirmed"
  309. def test_make_decision_exits_grid_early_on_fast_bearish_alignment():
  310. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  311. narrative = {
  312. "generated_at": "2026-04-18T20:15:00+00:00",
  313. "stance": "constructive_bearish",
  314. "confidence": 0.84,
  315. "opportunity_map": {"continuation": 0.8, "mean_reversion": 0.05, "reversal": 0.05, "wait": 0.1},
  316. "features_by_timeframe": {
  317. "1m": {"raw": {"price": 96.0, "atr_percent": 0.22, "rsi": 28.0, "macd_histogram": -0.02, "vwap": 97.2, "ema_fast": 96.8, "ema_slow": 97.6, "sma_long": 98.0, "bands": {"bollinger": {"middle": 97.0, "upper": 98.0, "lower": 95.5}}}},
  318. },
  319. "scoped_state": {
  320. "micro": {"impulse": "down", "trend_bias": "bearish", "location": "near_lower_band", "reversal_risk": "low"},
  321. "meso": {"structure": "trend_continuation", "momentum_bias": "bearish"},
  322. "macro": {"bias": "bearish"},
  323. },
  324. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  325. }
  326. wallet_state = {
  327. "inventory_state": "balanced",
  328. "rebalance_needed": False,
  329. "grid_ready": True,
  330. "base_ratio": 0.5,
  331. "quote_ratio": 0.5,
  332. }
  333. strategies = [
  334. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 100.0}, "config": {"grid_step_pct": 0.05}},
  335. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {"trade_side": "sell"}},
  336. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  337. ]
  338. history_window = {
  339. "window_seconds": 1800,
  340. "recent_states": [
  341. {"created_at": "2026-04-18T19:55:00+00:00", "payload_json": '{"features_by_timeframe":{"1m":{"raw":{"price":100.0}}}}'},
  342. {"created_at": "2026-04-18T20:00:00+00:00", "payload_json": '{"features_by_timeframe":{"1m":{"raw":{"price":99.2}}}}'},
  343. {"created_at": "2026-04-18T20:05:00+00:00", "payload_json": '{"features_by_timeframe":{"1m":{"raw":{"price":97.8}}}}'},
  344. {"created_at": "2026-04-18T20:10:00+00:00", "payload_json": '{"features_by_timeframe":{"1m":{"raw":{"price":96.8}}}}'},
  345. ],
  346. }
  347. decision = make_decision(
  348. concern=concern,
  349. narrative_payload=narrative,
  350. wallet_state=wallet_state,
  351. strategies=strategies,
  352. history_window=history_window,
  353. )
  354. assert decision.mode == "act"
  355. assert decision.action == "replace_with_trend_follower"
  356. assert decision.target_strategy == "trend-1"
  357. assert decision.payload["decision_audit"]["rapid_downside_pressure"] is True
  358. def test_make_decision_prefers_exposure_protector_when_downside_hits_skewed_wallet():
  359. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  360. narrative = {
  361. "generated_at": "2026-04-18T20:15:00+00:00",
  362. "stance": "constructive_bearish",
  363. "confidence": 0.84,
  364. "opportunity_map": {"continuation": 0.8, "mean_reversion": 0.05, "reversal": 0.05, "wait": 0.1},
  365. "features_by_timeframe": {
  366. "1m": {"raw": {"price": 96.0, "atr_percent": 0.22, "rsi": 28.0, "macd_histogram": -0.02, "vwap": 97.2, "ema_fast": 96.8, "ema_slow": 97.6, "sma_long": 98.0}},
  367. },
  368. "scoped_state": {
  369. "micro": {"impulse": "down", "trend_bias": "bearish", "location": "near_lower_band", "reversal_risk": "low"},
  370. "meso": {"structure": "trend_continuation", "momentum_bias": "bearish"},
  371. "macro": {"bias": "bearish"},
  372. },
  373. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  374. }
  375. wallet_state = {
  376. "inventory_state": "base_heavy",
  377. "rebalance_needed": True,
  378. "grid_ready": False,
  379. "base_ratio": 0.8,
  380. "quote_ratio": 0.2,
  381. }
  382. strategies = [
  383. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 100.0}, "config": {"grid_step_pct": 0.05}},
  384. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {"trade_side": "sell"}},
  385. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  386. ]
  387. history_window = {
  388. "window_seconds": 1800,
  389. "recent_states": [
  390. {"created_at": "2026-04-18T19:55:00+00:00", "payload_json": '{"features_by_timeframe":{"1m":{"raw":{"price":100.0}}}}'},
  391. {"created_at": "2026-04-18T20:00:00+00:00", "payload_json": '{"features_by_timeframe":{"1m":{"raw":{"price":99.2}}}}'},
  392. {"created_at": "2026-04-18T20:05:00+00:00", "payload_json": '{"features_by_timeframe":{"1m":{"raw":{"price":97.8}}}}'},
  393. {"created_at": "2026-04-18T20:10:00+00:00", "payload_json": '{"features_by_timeframe":{"1m":{"raw":{"price":96.8}}}}'},
  394. ],
  395. }
  396. decision = make_decision(
  397. concern=concern,
  398. narrative_payload=narrative,
  399. wallet_state=wallet_state,
  400. strategies=strategies,
  401. history_window=history_window,
  402. )
  403. assert decision.mode == "act"
  404. assert decision.action == "replace_with_exposure_protector"
  405. assert decision.target_strategy == "protect-1"
  406. def test_make_decision_prefers_exposure_protector_when_upside_hits_skewed_wallet():
  407. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  408. narrative = {
  409. "generated_at": "2026-04-18T20:15:00+00:00",
  410. "stance": "constructive_bullish",
  411. "confidence": 0.84,
  412. "opportunity_map": {"continuation": 0.8, "mean_reversion": 0.05, "reversal": 0.05, "wait": 0.1},
  413. "features_by_timeframe": {
  414. "1m": {"raw": {"price": 104.0, "atr_percent": 0.22, "rsi": 72.0, "macd_histogram": 0.02, "vwap": 102.8, "ema_fast": 103.2, "ema_slow": 102.4, "sma_long": 102.0}},
  415. },
  416. "scoped_state": {
  417. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "low"},
  418. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  419. "macro": {"bias": "bullish"},
  420. },
  421. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  422. }
  423. wallet_state = {
  424. "inventory_state": "quote_heavy",
  425. "rebalance_needed": True,
  426. "grid_ready": False,
  427. "base_ratio": 0.2,
  428. "quote_ratio": 0.8,
  429. }
  430. strategies = [
  431. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 100.0}, "config": {"grid_step_pct": 0.05}},
  432. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {"trade_side": "buy"}},
  433. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  434. ]
  435. history_window = {
  436. "window_seconds": 1800,
  437. "recent_states": [
  438. {"created_at": "2026-04-18T19:55:00+00:00", "payload_json": '{"features_by_timeframe":{"1m":{"raw":{"price":100.0}}}}'},
  439. {"created_at": "2026-04-18T20:00:00+00:00", "payload_json": '{"features_by_timeframe":{"1m":{"raw":{"price":100.8}}}}'},
  440. {"created_at": "2026-04-18T20:05:00+00:00", "payload_json": '{"features_by_timeframe":{"1m":{"raw":{"price":102.2}}}}'},
  441. {"created_at": "2026-04-18T20:10:00+00:00", "payload_json": '{"features_by_timeframe":{"1m":{"raw":{"price":103.4}}}}'},
  442. ],
  443. }
  444. decision = make_decision(
  445. concern=concern,
  446. narrative_payload=narrative,
  447. wallet_state=wallet_state,
  448. strategies=strategies,
  449. history_window=history_window,
  450. )
  451. assert decision.mode == "act"
  452. assert decision.action == "replace_with_exposure_protector"
  453. assert decision.target_strategy == "protect-1"
  454. def test_make_decision_targets_the_trade_side_that_matches_direction():
  455. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  456. narrative = {
  457. "stance": "constructive_bullish",
  458. "confidence": 0.82,
  459. "opportunity_map": {"continuation": 0.82, "mean_reversion": 0.05, "reversal": 0.03, "wait": 0.1},
  460. "scoped_state": {
  461. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "low"},
  462. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  463. "macro": {"bias": "bullish"},
  464. },
  465. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  466. "features_by_timeframe": {"1m": {"raw": {"price": 116.0}}},
  467. }
  468. wallet_state = {"inventory_state": "balanced", "rebalance_needed": False, "grid_ready": True, "base_ratio": 0.52, "quote_ratio": 0.48}
  469. strategies = [
  470. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 100.0}, "config": {"grid_step_pct": 0.05}},
  471. {"id": "trend-long", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {"trade_side": "buy"}},
  472. {"id": "trend-short", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {"trade_side": "sell"}},
  473. ]
  474. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  475. assert decision.mode == "act"
  476. assert decision.action == "replace_with_trend_follower"
  477. assert decision.target_strategy == "trend-long"
  478. def test_make_decision_marks_breakout_as_developing_under_partial_alignment():
  479. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  480. narrative = {
  481. "stance": "cautious_bullish",
  482. "confidence": 0.76,
  483. "opportunity_map": {"continuation": 0.62, "mean_reversion": 0.12, "reversal": 0.06, "wait": 0.2},
  484. "scoped_state": {
  485. "micro": {"impulse": "up", "trend_bias": "bullish", "reversal_risk": "low"},
  486. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  487. "macro": {"bias": "bullish"},
  488. },
  489. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  490. }
  491. wallet_state = {
  492. "inventory_state": "base_heavy",
  493. "rebalance_needed": True,
  494. "grid_ready": False,
  495. "base_ratio": 0.74,
  496. "quote_ratio": 0.26,
  497. }
  498. strategies = [
  499. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": False, "side_capacity": {"buy": True, "sell": False}}}},
  500. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  501. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  502. ]
  503. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  504. assert decision.action == "keep_grid"
  505. assert decision.payload["grid_breakout_pressure"]["phase"] == "developing"
  506. assert decision.reason_summary == "breakout pressure is developing, but grid can still work and should not be abandoned yet"
  507. def test_make_decision_argus_compression_stays_context_only():
  508. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  509. narrative = {
  510. "stance": "constructive_bullish",
  511. "confidence": 0.82,
  512. "opportunity_map": {"continuation": 0.82, "mean_reversion": 0.05, "reversal": 0.03, "wait": 0.1},
  513. "scoped_state": {
  514. "micro": {"impulse": "up", "trend_bias": "bullish", "reversal_risk": "low"},
  515. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  516. "macro": {"bias": "bullish"},
  517. },
  518. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  519. "argus_context": {
  520. "regime": "compression",
  521. "regime_confidence": 0.72,
  522. "regime_components": {"compression": 0.81},
  523. },
  524. "features_by_timeframe": {"1m": {"raw": {"price": 112.0}}},
  525. }
  526. wallet_state = {
  527. "inventory_state": "balanced",
  528. "rebalance_needed": False,
  529. "grid_ready": True,
  530. "base_ratio": 0.52,
  531. "quote_ratio": 0.48,
  532. }
  533. strategies = [
  534. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 100.0}, "config": {"grid_step_pct": 0.05}},
  535. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  536. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  537. ]
  538. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  539. assert decision.action == "keep_grid"
  540. assert decision.payload["grid_breakout_pressure"]["argus_compression_active"] is True
  541. assert decision.payload["grid_breakout_pressure"]["phase"] == "confirmed"
  542. assert decision.payload["argus_decision_context"]["compression_active"] is True
  543. def test_make_decision_keeps_grid_when_1m_and_5m_trend_is_only_partial_confirmation():
  544. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  545. narrative = {
  546. "stance": "constructive_bullish",
  547. "confidence": 0.91,
  548. "opportunity_map": {"continuation": 0.9, "mean_reversion": 0.03, "reversal": 0.02, "wait": 0.05},
  549. "features_by_timeframe": {
  550. "1m": {
  551. "raw": {"price": 1.4334},
  552. "trend": {"alignment": "mixed", "strength": 0.12, "bias_score": 0.49},
  553. "momentum": {"impulse": "high"},
  554. },
  555. "5m": {
  556. "raw": {"price": 1.4334},
  557. "trend": {"alignment": "bullish_pullback", "strength": 0.32, "bias_score": 1.30},
  558. "momentum": {"impulse": "medium"},
  559. },
  560. "15m": {
  561. "raw": {"price": 1.4334},
  562. "trend": {"alignment": "bullish_pullback", "strength": 0.38, "bias_score": 1.51},
  563. "momentum": {"impulse": "medium"},
  564. },
  565. "1h": {
  566. "raw": {"price": 1.4312},
  567. "trend": {"alignment": "fully_bullish", "strength": 0.71, "bias_score": 2.83},
  568. "momentum": {"impulse": "medium"},
  569. },
  570. "4h": {
  571. "raw": {"price": 1.4308},
  572. "trend": {"alignment": "fully_bullish", "strength": 1.0, "bias_score": 4.64},
  573. "momentum": {"impulse": "low"},
  574. },
  575. "1d": {
  576. "raw": {"price": 1.4311},
  577. "trend": {"alignment": "bearish_pullback", "strength": 0.28, "bias_score": -1.13},
  578. "momentum": {"impulse": "medium"},
  579. },
  580. },
  581. "scoped_state": {
  582. "micro": {"impulse": "mixed", "trend_bias": "bullish", "location": "upper_half", "reversal_risk": "low"},
  583. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  584. "macro": {"bias": "bullish"},
  585. },
  586. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  587. "decision_inputs": {
  588. "structural_direction": "bullish",
  589. "structural_trend_strength": 0.9,
  590. "tactical_direction": "bullish",
  591. "tactical_trend_strength": 0.55,
  592. "tactical_range_quality": 0.0,
  593. "tactical_easing": False,
  594. "micro_location": "upper_half",
  595. "micro_atr_percent": 0.0606,
  596. "micro_bollinger_width_pct": 0.2976,
  597. },
  598. }
  599. wallet_state = {
  600. "inventory_state": "base_heavy",
  601. "rebalance_needed": True,
  602. "grid_ready": False,
  603. "base_ratio": 0.7481,
  604. "quote_ratio": 0.2519,
  605. }
  606. strategies = [
  607. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 1.4293}, "config": {"grid_step_pct": 0.0125}},
  608. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  609. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  610. ]
  611. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  612. assert decision.action == "keep_grid"
  613. assert decision.target_strategy == "grid-1"
  614. assert decision.payload["grid_switch_tradeoff"]["short_term_trend_score"] < 0.68
  615. def test_make_decision_promotes_developing_breakout_from_time_window_memory():
  616. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  617. narrative = {
  618. "generated_at": "2026-04-18T20:15:00+00:00",
  619. "stance": "cautious_bullish",
  620. "confidence": 0.76,
  621. "opportunity_map": {"continuation": 0.62, "mean_reversion": 0.12, "reversal": 0.06, "wait": 0.2},
  622. "scoped_state": {
  623. "micro": {"impulse": "up", "trend_bias": "bullish", "reversal_risk": "low"},
  624. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  625. "macro": {"bias": "bullish"},
  626. },
  627. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  628. }
  629. wallet_state = {
  630. "inventory_state": "base_heavy",
  631. "rebalance_needed": True,
  632. "grid_ready": False,
  633. "base_ratio": 0.74,
  634. "quote_ratio": 0.26,
  635. }
  636. strategies = [
  637. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": False, "side_capacity": {"buy": True, "sell": False}}}},
  638. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  639. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  640. ]
  641. history_window = {
  642. "window_seconds": 15 * 60,
  643. "recent_states": [
  644. {
  645. "created_at": "2026-04-18T20:06:00+00:00",
  646. "payload_json": '{"scoped_state":{"micro":{"impulse":"up","trend_bias":"bullish"},"meso":{"structure":"trend_continuation","momentum_bias":"bullish"},"macro":{"bias":"bullish"}},"cross_scope_summary":{"alignment":"partial_alignment","friction":"medium"}}',
  647. },
  648. {
  649. "created_at": "2026-04-18T20:10:30+00:00",
  650. "payload_json": '{"scoped_state":{"micro":{"impulse":"up","trend_bias":"bullish"},"meso":{"structure":"trend_continuation","momentum_bias":"bullish"},"macro":{"bias":"bullish"}},"cross_scope_summary":{"alignment":"partial_alignment","friction":"medium"}}',
  651. },
  652. ],
  653. }
  654. decision = make_decision(
  655. concern=concern,
  656. narrative_payload=narrative,
  657. wallet_state=wallet_state,
  658. strategies=strategies,
  659. history_window=history_window,
  660. )
  661. assert decision.payload["grid_breakout_pressure"]["phase"] == "confirmed"
  662. assert decision.payload["grid_breakout_pressure"]["time_window_memory"]["promoted_to_confirmed"] is True
  663. assert decision.payload["grid_breakout_pressure"]["time_window_memory"]["same_direction_seconds"] >= 540
  664. def test_make_decision_replaces_grid_with_trend_when_breakout_is_persistent_but_inventory_is_only_base_heavy():
  665. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  666. narrative = {
  667. "stance": "constructive_bullish",
  668. "confidence": 0.78,
  669. "opportunity_map": {"continuation": 0.72, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.15},
  670. "scoped_state": {
  671. "micro": {"impulse": "up", "trend_bias": "bullish"},
  672. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  673. "macro": {"bias": "bullish"},
  674. },
  675. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  676. }
  677. wallet_state = {
  678. "inventory_state": "base_heavy",
  679. "rebalance_needed": True,
  680. "grid_ready": False,
  681. "base_ratio": 0.64,
  682. "quote_ratio": 0.36,
  683. }
  684. strategies = [
  685. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": False, "side_capacity": {"buy": True, "sell": False}}}},
  686. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  687. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  688. ]
  689. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  690. assert decision.action == "keep_grid"
  691. assert decision.target_strategy == "grid-1"
  692. def test_make_decision_prefers_active_grid_over_observe_trend_as_current_primary():
  693. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  694. narrative = {
  695. "stance": "constructive_bullish",
  696. "confidence": 0.72,
  697. "opportunity_map": {"continuation": 0.75, "mean_reversion": 0.1, "reversal": 0.05, "wait": 0.1},
  698. }
  699. wallet_state = {
  700. "inventory_state": "base_heavy",
  701. "rebalance_needed": True,
  702. "grid_ready": False,
  703. "base_ratio": 0.81,
  704. "quote_ratio": 0.19,
  705. }
  706. strategies = [
  707. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
  708. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  709. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  710. ]
  711. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  712. assert decision.action == "keep_grid"
  713. assert decision.target_strategy == "grid-1"
  714. def test_make_decision_prefers_trend_over_rebalancer_on_bullish_breakout_with_depleted_base():
  715. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  716. narrative = {
  717. "stance": "constructive_bullish",
  718. "confidence": 0.9,
  719. "opportunity_map": {"continuation": 0.85, "mean_reversion": 0.05, "reversal": 0.02, "wait": 0.08},
  720. "scoped_state": {
  721. "micro": {"impulse": "up", "trend_bias": "bullish"},
  722. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  723. "macro": {"bias": "bullish"},
  724. },
  725. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  726. }
  727. wallet_state = {
  728. "inventory_state": "critically_unbalanced",
  729. "rebalance_needed": True,
  730. "grid_ready": False,
  731. "base_ratio": 0.0,
  732. "quote_ratio": 1.0,
  733. }
  734. strategies = [
  735. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  736. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  737. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  738. ]
  739. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  740. assert decision.action == "replace_with_exposure_protector"
  741. assert decision.target_strategy == "protect-1"
  742. def test_make_decision_replaces_grid_when_next_sell_is_close_but_confirmed_trend_handoff_is_ready():
  743. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  744. narrative = {
  745. "stance": "constructive_bullish",
  746. "confidence": 0.9,
  747. "opportunity_map": {"continuation": 0.85, "mean_reversion": 0.05, "reversal": 0.02, "wait": 0.08},
  748. "features_by_timeframe": {
  749. "1m": {"raw": {"price": 1.4374, "atr_percent": 0.11}},
  750. },
  751. "scoped_state": {
  752. "micro": {"impulse": "up", "trend_bias": "bullish"},
  753. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  754. "macro": {"bias": "bullish"},
  755. },
  756. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  757. }
  758. wallet_state = {
  759. "inventory_state": "base_heavy",
  760. "rebalance_needed": True,
  761. "grid_ready": False,
  762. "base_ratio": 0.65,
  763. "quote_ratio": 0.35,
  764. }
  765. strategies = [
  766. {
  767. "id": "grid-1",
  768. "strategy_type": "grid_trader",
  769. "mode": "active",
  770. "account_id": "a1",
  771. "market_symbol": "xrpusd",
  772. "state": {
  773. "last_price": 1.4374,
  774. "center_price": 1.24,
  775. "orders": [
  776. {"side": "sell", "status": "open", "price": "1.43956", "amount": "7"},
  777. {"side": "buy", "status": "open", "price": "1.42523", "amount": "7"},
  778. ],
  779. },
  780. "config": {"grid_step_pct": 0.05},
  781. },
  782. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
  783. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  784. ]
  785. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  786. assert decision.action == "replace_with_trend_follower"
  787. assert decision.target_strategy == "trend-1"
  788. assert decision.payload["grid_fill_context"]["near_fill_side"] == "sell"
  789. def test_make_decision_replaces_grid_when_time_promoted_confirmation_clears_lower_handoff_threshold():
  790. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  791. narrative = {
  792. "generated_at": "2026-04-18T20:15:00+00:00",
  793. "stance": "breakout_watch",
  794. "confidence": 0.78,
  795. "opportunity_map": {"continuation": 0.7, "mean_reversion": 0.1, "reversal": 0.04, "wait": 0.16},
  796. "features_by_timeframe": {
  797. "1m": {"raw": {"price": 111.0, "atr_percent": 0.35}},
  798. },
  799. "scoped_state": {
  800. "micro": {"impulse": "up", "trend_bias": "bullish", "reversal_risk": "low"},
  801. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  802. "macro": {"bias": "bullish"},
  803. },
  804. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  805. }
  806. wallet_state = {
  807. "inventory_state": "base_heavy",
  808. "rebalance_needed": True,
  809. "grid_ready": False,
  810. "base_ratio": 0.66,
  811. "quote_ratio": 0.34,
  812. }
  813. strategies = [
  814. {
  815. "id": "grid-1",
  816. "strategy_type": "grid_trader",
  817. "mode": "active",
  818. "account_id": "a1",
  819. "market_symbol": "xrpusd",
  820. "state": {"last_price": 111.0, "center_price": 100.0},
  821. "config": {"grid_step_pct": 0.05},
  822. },
  823. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
  824. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  825. ]
  826. history_window = {
  827. "window_seconds": 15 * 60,
  828. "recent_states": [
  829. {
  830. "created_at": "2026-04-18T20:06:00+00:00",
  831. "payload_json": '{"scoped_state":{"micro":{"impulse":"up","trend_bias":"bullish"},"meso":{"structure":"trend_continuation","momentum_bias":"bullish"},"macro":{"bias":"bullish"}},"cross_scope_summary":{"alignment":"partial_alignment","friction":"medium"}}',
  832. },
  833. {
  834. "created_at": "2026-04-18T20:10:30+00:00",
  835. "payload_json": '{"scoped_state":{"micro":{"impulse":"up","trend_bias":"bullish"},"meso":{"structure":"trend_continuation","momentum_bias":"bullish"},"macro":{"bias":"bullish"}},"cross_scope_summary":{"alignment":"partial_alignment","friction":"medium"}}',
  836. },
  837. ],
  838. }
  839. decision = make_decision(
  840. concern=concern,
  841. narrative_payload=narrative,
  842. wallet_state=wallet_state,
  843. strategies=strategies,
  844. history_window=history_window,
  845. )
  846. assert decision.action == "replace_with_trend_follower"
  847. assert decision.target_strategy == "trend-1"
  848. assert decision.payload["grid_breakout_pressure"]["time_window_memory"]["promoted_to_confirmed"] is True
  849. def test_normalize_strategy_snapshot_uses_live_report_contract_and_supervision():
  850. normalized = normalize_strategy_snapshot({
  851. "id": "grid-1",
  852. "strategy_type": "grid_trader",
  853. "mode": "active",
  854. "account_id": "a1",
  855. "report": {
  856. "fit": {
  857. "role": "primary",
  858. "inventory_behavior": "balanced",
  859. "safe_when_unbalanced": False,
  860. "can_run_with": ["exposure_protector"],
  861. },
  862. "state": {"last_action": "hold", "open_order_count": 12},
  863. "supervision": {"inventory_pressure": "base_heavy", "capacity_available": False, "side_capacity": {"buy": True, "sell": False}, "degraded": False},
  864. },
  865. })
  866. assert normalized["contract"]["inventory_behavior"] == "balanced"
  867. assert normalized["supervision"]["capacity_available"] is False
  868. assert normalized["open_order_count"] == 12
  869. def test_make_decision_keeps_trend_during_strong_directional_regime():
  870. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  871. narrative = {
  872. "stance": "constructive_bullish",
  873. "confidence": 0.7,
  874. "opportunity_map": {"continuation": 0.8, "mean_reversion": 0.1, "reversal": 0.05, "wait": 0.05},
  875. }
  876. wallet_state = {
  877. "inventory_state": "balanced",
  878. "rebalance_needed": False,
  879. "grid_ready": True,
  880. "base_ratio": 0.52,
  881. "quote_ratio": 0.48,
  882. }
  883. strategies = [
  884. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  885. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  886. ]
  887. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  888. assert decision.mode == "observe"
  889. assert decision.action == "keep_trend"
  890. assert decision.target_strategy == "trend-1"
  891. def test_make_decision_replaces_trend_with_rebalancer_after_trend_cools_and_wallet_needs_repair():
  892. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  893. narrative = {
  894. "stance": "neutral_rotational",
  895. "confidence": 0.65,
  896. "opportunity_map": {"continuation": 0.15, "mean_reversion": 0.25, "reversal": 0.2, "wait": 0.4},
  897. }
  898. wallet_state = {
  899. "inventory_state": "critically_unbalanced",
  900. "rebalance_needed": True,
  901. "grid_ready": False,
  902. "base_ratio": 0.88,
  903. "quote_ratio": 0.12,
  904. }
  905. strategies = [
  906. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  907. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  908. ]
  909. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  910. assert decision.mode == "act"
  911. assert decision.action == "replace_with_exposure_protector"
  912. assert decision.target_strategy == "protect-1"
  913. def test_make_decision_replaces_trend_with_rebalancer_on_edge_cooling_even_before_full_rotational_stance():
  914. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  915. narrative = {
  916. "stance": "constructive_bullish",
  917. "confidence": 0.74,
  918. "opportunity_map": {"continuation": 0.58, "mean_reversion": 0.12, "reversal": 0.08, "wait": 0.22},
  919. "scoped_state": {
  920. "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "near_upper_band"},
  921. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  922. "macro": {"bias": "bullish"},
  923. },
  924. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  925. }
  926. wallet_state = {
  927. "inventory_state": "base_heavy",
  928. "rebalance_needed": True,
  929. "grid_ready": False,
  930. "base_ratio": 0.74,
  931. "quote_ratio": 0.26,
  932. }
  933. strategies = [
  934. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  935. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  936. ]
  937. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  938. assert decision.mode == "act"
  939. assert decision.action == "replace_with_exposure_protector"
  940. assert decision.target_strategy == "protect-1"
  941. def test_make_decision_replaces_trend_with_rebalancer_when_1m_and_5m_dislocate_from_higher_trends():
  942. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  943. narrative = {
  944. "stance": "constructive_bullish",
  945. "confidence": 0.76,
  946. "opportunity_map": {"continuation": 0.6, "mean_reversion": 0.12, "reversal": 0.08, "wait": 0.2},
  947. "scoped_state": {
  948. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "low"},
  949. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  950. "macro": {"bias": "bullish"},
  951. },
  952. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  953. "features_by_timeframe": {
  954. "1m": {"trend": {"bias_score": 0.82, "alignment": "fully_bullish"}, "momentum": {"impulse": "up"}},
  955. "5m": {"trend": {"bias_score": -0.74, "alignment": "fully_bearish"}, "momentum": {"impulse": "down"}},
  956. "15m": {"trend": {"bias_score": 0.66, "alignment": "fully_bullish"}, "momentum": {"impulse": "up"}},
  957. "1h": {"trend": {"bias_score": 0.7, "alignment": "fully_bullish"}, "momentum": {"impulse": "up"}},
  958. },
  959. }
  960. wallet_state = {
  961. "inventory_state": "base_heavy",
  962. "rebalance_needed": True,
  963. "grid_ready": False,
  964. "base_ratio": 0.74,
  965. "quote_ratio": 0.26,
  966. }
  967. strategies = [
  968. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  969. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  970. ]
  971. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  972. assert decision.mode == "act"
  973. assert decision.action == "replace_with_exposure_protector"
  974. assert decision.target_strategy == "protect-1"
  975. def test_make_decision_replaces_trend_with_rebalancer_when_short_tape_is_mixed_and_inventory_is_base_heavy():
  976. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  977. narrative = {
  978. "stance": "constructive_bullish",
  979. "confidence": 0.77,
  980. "opportunity_map": {"continuation": 0.61, "mean_reversion": 0.12, "reversal": 0.08, "wait": 0.19},
  981. "scoped_state": {
  982. "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "lower_half", "reversal_risk": "low"},
  983. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  984. "macro": {"bias": "bullish"},
  985. },
  986. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  987. "features_by_timeframe": {
  988. "1m": {"trend": {"bias_score": -0.12, "alignment": "mixed"}, "momentum": {"impulse": "medium"}},
  989. "5m": {"trend": {"bias_score": 0.88, "alignment": "bullish_pullback"}, "momentum": {"impulse": "low"}},
  990. "15m": {"trend": {"bias_score": 0.95, "alignment": "bullish_pullback"}, "momentum": {"impulse": "low"}},
  991. "1h": {"trend": {"bias_score": 2.6, "alignment": "fully_bullish"}, "momentum": {"impulse": "medium"}},
  992. },
  993. }
  994. wallet_state = {
  995. "inventory_state": "depleted_quote_side",
  996. "rebalance_needed": True,
  997. "grid_ready": False,
  998. "base_ratio": 0.99,
  999. "quote_ratio": 0.01,
  1000. }
  1001. strategies = [
  1002. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  1003. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  1004. ]
  1005. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  1006. assert decision.mode == "act"
  1007. assert decision.action == "replace_with_exposure_protector"
  1008. assert decision.target_strategy == "protect-1"
  1009. def test_make_decision_replaces_trend_with_rebalancer_when_micro_reversal_risk_spikes():
  1010. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  1011. narrative = {
  1012. "stance": "constructive_bullish",
  1013. "confidence": 0.76,
  1014. "opportunity_map": {"continuation": 0.62, "mean_reversion": 0.1, "reversal": 0.18, "wait": 0.1},
  1015. "scoped_state": {
  1016. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "high"},
  1017. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  1018. "macro": {"bias": "bullish"},
  1019. },
  1020. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  1021. }
  1022. wallet_state = {
  1023. "inventory_state": "base_heavy",
  1024. "rebalance_needed": True,
  1025. "grid_ready": False,
  1026. "base_ratio": 0.72,
  1027. "quote_ratio": 0.28,
  1028. }
  1029. strategies = [
  1030. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  1031. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  1032. ]
  1033. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  1034. assert decision.mode == "act"
  1035. assert decision.action == "replace_with_exposure_protector"
  1036. assert decision.target_strategy == "protect-1"
  1037. def test_make_decision_replaces_rebalancer_with_grid_when_balanced_and_rotational():
  1038. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  1039. narrative = {
  1040. "stance": "neutral_rotational",
  1041. "confidence": 0.68,
  1042. "opportunity_map": {"continuation": 0.15, "mean_reversion": 0.72, "reversal": 0.05, "wait": 0.08},
  1043. }
  1044. wallet_state = {
  1045. "inventory_state": "balanced",
  1046. "rebalance_needed": False,
  1047. "grid_ready": True,
  1048. "base_ratio": 0.49,
  1049. "quote_ratio": 0.51,
  1050. }
  1051. strategies = [
  1052. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  1053. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  1054. ]
  1055. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  1056. assert decision.mode == "act"
  1057. assert decision.action == "replace_with_grid"
  1058. assert decision.target_strategy == "grid-1"
  1059. def test_make_decision_replaces_rebalancer_with_grid_when_wallet_is_rebalanced_even_if_trend_is_still_hot():
  1060. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  1061. narrative = {
  1062. "stance": "constructive_bullish",
  1063. "confidence": 0.84,
  1064. "opportunity_map": {"continuation": 0.82, "mean_reversion": 0.08, "reversal": 0.03, "wait": 0.07},
  1065. "scoped_state": {
  1066. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "low"},
  1067. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  1068. "macro": {"bias": "bullish"},
  1069. },
  1070. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  1071. }
  1072. wallet_state = {
  1073. "inventory_state": "balanced",
  1074. "rebalance_needed": False,
  1075. "grid_ready": True,
  1076. "base_ratio": 0.61,
  1077. "quote_ratio": 0.39,
  1078. }
  1079. strategies = [
  1080. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  1081. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": True, "side_capacity": {"buy": True, "sell": True}, "inventory_pressure": "balanced", "degraded": False}}},
  1082. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": True, "trend_strength": 0.94, "inventory_pressure": "balanced", "degraded": False}}},
  1083. ]
  1084. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  1085. assert decision.mode == "act"
  1086. assert decision.action == "replace_with_grid"
  1087. assert decision.target_strategy == "grid-1"
  1088. assert "rebalanced" in decision.reason_summary
  1089. def test_make_decision_replaces_rebalancer_with_grid_when_within_tolerance_even_before_perfect_balance():
  1090. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  1091. narrative = {
  1092. "stance": "neutral_rotational",
  1093. "confidence": 0.7,
  1094. "opportunity_map": {"continuation": 0.18, "mean_reversion": 0.68, "reversal": 0.05, "wait": 0.09},
  1095. }
  1096. wallet_state = {
  1097. "inventory_state": "base_heavy",
  1098. "rebalance_needed": True,
  1099. "grid_ready": True,
  1100. "base_ratio": 0.71,
  1101. "quote_ratio": 0.29,
  1102. }
  1103. strategies = [
  1104. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  1105. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  1106. ]
  1107. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  1108. assert decision.mode == "act"
  1109. assert decision.action == "replace_with_grid"
  1110. assert decision.target_strategy == "grid-1"
  1111. def test_make_decision_replaces_rebalancer_with_grid_when_trend_is_directional_but_not_sustained():
  1112. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  1113. narrative = {
  1114. "stance": "constructive_bullish",
  1115. "confidence": 0.72,
  1116. "opportunity_map": {"continuation": 0.4, "mean_reversion": 0.4, "reversal": 0.08, "wait": 0.12},
  1117. "scoped_state": {
  1118. "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "centered", "reversal_risk": "low"},
  1119. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  1120. "macro": {"bias": "bullish"},
  1121. },
  1122. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  1123. }
  1124. wallet_state = {
  1125. "inventory_state": "balanced",
  1126. "rebalance_needed": False,
  1127. "grid_ready": True,
  1128. "base_ratio": 0.51,
  1129. "quote_ratio": 0.49,
  1130. }
  1131. strategies = [
  1132. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  1133. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": True, "side_capacity": {"buy": True, "sell": True}, "inventory_pressure": "balanced", "degraded": False}}},
  1134. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": True, "trend_strength": 0.58, "inventory_pressure": "balanced", "degraded": False}}},
  1135. ]
  1136. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  1137. assert decision.mode == "act"
  1138. assert decision.action == "replace_with_grid"
  1139. assert decision.target_strategy == "grid-1"
  1140. def test_make_decision_replaces_rebalancer_with_grid_when_micro_easing_restores_harvestability():
  1141. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  1142. narrative = {
  1143. "stance": "constructive_bearish",
  1144. "confidence": 0.73,
  1145. "opportunity_map": {"continuation": 0.42, "mean_reversion": 0.38, "reversal": 0.08, "wait": 0.12},
  1146. "scoped_state": {
  1147. "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "centered", "reversal_risk": "low"},
  1148. "meso": {"structure": "trend_continuation", "momentum_bias": "bearish"},
  1149. "macro": {"bias": "bearish"},
  1150. },
  1151. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  1152. "features_by_timeframe": {
  1153. "1m": {
  1154. "raw": {"price": 1.01, "atr_percent": 0.42},
  1155. "volatility": {"bollinger_width_pct": 1.35},
  1156. },
  1157. },
  1158. }
  1159. wallet_state = {
  1160. "inventory_state": "quote_heavy",
  1161. "rebalance_needed": True,
  1162. "grid_ready": False,
  1163. "base_ratio": 0.29,
  1164. "quote_ratio": 0.71,
  1165. }
  1166. strategies = [
  1167. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  1168. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {"grid_step_pct": 0.005}},
  1169. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  1170. ]
  1171. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  1172. assert decision.mode == "act"
  1173. assert decision.action == "replace_with_grid"
  1174. assert decision.target_strategy == "grid-1"
  1175. assert decision.payload["decision_audit"]["tactical_easing"] is True
  1176. assert decision.payload["decision_audit"]["grid_harvestable_now"] is True
  1177. assert decision.payload["decision_audit"]["rebalancer_release_ready"] is True
  1178. def test_make_decision_replaces_rebalancer_with_grid_near_local_bottom_when_noise_exceeds_grid_size():
  1179. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  1180. narrative = {
  1181. "stance": "cautious_bearish",
  1182. "confidence": 0.69,
  1183. "opportunity_map": {"continuation": 0.34, "mean_reversion": 0.44, "reversal": 0.1, "wait": 0.12},
  1184. "scoped_state": {
  1185. "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "near_lower_band", "reversal_risk": "medium"},
  1186. "meso": {"structure": "trend_continuation", "momentum_bias": "bearish"},
  1187. "macro": {"bias": "bearish"},
  1188. },
  1189. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  1190. "features_by_timeframe": {
  1191. "1m": {
  1192. "raw": {"price": 0.992, "atr_percent": 0.48},
  1193. "volatility": {"bollinger_width_pct": 1.6},
  1194. },
  1195. },
  1196. }
  1197. wallet_state = {
  1198. "inventory_state": "quote_heavy",
  1199. "rebalance_needed": True,
  1200. "grid_ready": False,
  1201. "base_ratio": 0.31,
  1202. "quote_ratio": 0.69,
  1203. }
  1204. strategies = [
  1205. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  1206. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {"grid_step_pct": 0.005}},
  1207. ]
  1208. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  1209. assert decision.mode == "act"
  1210. assert decision.action == "replace_with_grid"
  1211. assert decision.target_strategy == "grid-1"
  1212. assert decision.payload["decision_audit"]["pullback_to_grid_ratio"] is not None
  1213. assert decision.payload["decision_audit"]["pullback_to_grid_ratio"] > 2.0
  1214. def test_make_decision_replaces_grid_with_trend_when_pullbacks_are_too_shallow_for_grid_step():
  1215. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  1216. narrative = {
  1217. "generated_at": "2026-04-19T19:05:00+00:00",
  1218. "stance": "constructive_bullish",
  1219. "confidence": 0.88,
  1220. "opportunity_map": {"continuation": 0.84, "mean_reversion": 0.05, "reversal": 0.03, "wait": 0.08},
  1221. "scoped_state": {
  1222. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "low"},
  1223. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  1224. "macro": {"bias": "bullish"},
  1225. },
  1226. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  1227. "features_by_timeframe": {
  1228. "1m": {
  1229. "raw": {"price": 104.2, "atr_percent": 0.11},
  1230. "volatility": {"bollinger_width_pct": 0.24},
  1231. },
  1232. },
  1233. }
  1234. wallet_state = {
  1235. "inventory_state": "base_heavy",
  1236. "rebalance_needed": True,
  1237. "grid_ready": False,
  1238. "base_ratio": 0.67,
  1239. "quote_ratio": 0.33,
  1240. }
  1241. strategies = [
  1242. {
  1243. "id": "grid-1",
  1244. "strategy_type": "grid_trader",
  1245. "mode": "active",
  1246. "account_id": "a1",
  1247. "market_symbol": "xrpusd",
  1248. "state": {
  1249. "last_price": 104.2,
  1250. "center_price": 100.0,
  1251. "orders": [
  1252. {"side": "sell", "status": "open", "price": "104.7", "amount": "5"},
  1253. {"side": "buy", "status": "open", "price": "103.7", "amount": "5"},
  1254. ],
  1255. },
  1256. "config": {"grid_step_pct": 0.005},
  1257. },
  1258. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
  1259. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  1260. ]
  1261. history_window = {
  1262. "window_seconds": 15 * 60,
  1263. "recent_states": [
  1264. {
  1265. "created_at": "2026-04-19T18:55:00+00:00",
  1266. "payload_json": '{"scoped_state":{"micro":{"impulse":"up","trend_bias":"bullish"},"meso":{"structure":"trend_continuation","momentum_bias":"bullish"},"macro":{"bias":"bullish"}},"cross_scope_summary":{"alignment":"micro_meso_macro_aligned","friction":"low"}}',
  1267. },
  1268. {
  1269. "created_at": "2026-04-19T19:00:30+00:00",
  1270. "payload_json": '{"scoped_state":{"micro":{"impulse":"up","trend_bias":"bullish"},"meso":{"structure":"trend_continuation","momentum_bias":"bullish"},"macro":{"bias":"bullish"}},"cross_scope_summary":{"alignment":"micro_meso_macro_aligned","friction":"low"}}',
  1271. },
  1272. ],
  1273. }
  1274. decision = make_decision(
  1275. concern=concern,
  1276. narrative_payload=narrative,
  1277. wallet_state=wallet_state,
  1278. strategies=strategies,
  1279. history_window=history_window,
  1280. )
  1281. assert decision.mode == "act"
  1282. assert decision.action == "replace_with_trend_follower"
  1283. assert decision.target_strategy == "trend-1"
  1284. assert decision.payload["decision_audit"]["pullback_to_grid_ratio"] < 1.0
  1285. assert decision.payload["decision_audit"]["trend_following_pressure"] is True
  1286. def test_make_decision_replaces_rebalancer_with_trend_when_breakout_is_still_strong():
  1287. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  1288. narrative = {
  1289. "stance": "constructive_bullish",
  1290. "confidence": 0.84,
  1291. "opportunity_map": {"continuation": 0.82, "mean_reversion": 0.08, "reversal": 0.03, "wait": 0.07},
  1292. "scoped_state": {
  1293. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "low"},
  1294. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  1295. "macro": {"bias": "bullish"},
  1296. },
  1297. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  1298. }
  1299. wallet_state = {
  1300. "inventory_state": "base_heavy",
  1301. "rebalance_needed": True,
  1302. "grid_ready": False,
  1303. "base_ratio": 0.74,
  1304. "quote_ratio": 0.26,
  1305. }
  1306. strategies = [
  1307. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  1308. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": True, "side_capacity": {"buy": True, "sell": True}, "inventory_pressure": "balanced", "degraded": False}}},
  1309. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"capacity_available": True, "trend_strength": 0.92, "inventory_pressure": "balanced", "degraded": False}}},
  1310. ]
  1311. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  1312. assert decision.mode == "observe"
  1313. assert decision.action == "keep_rebalancer"
  1314. assert decision.target_strategy == "protect-1"