test_decision_engine.py 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069
  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_targets_the_trade_side_that_matches_direction():
  310. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  311. narrative = {
  312. "stance": "constructive_bullish",
  313. "confidence": 0.82,
  314. "opportunity_map": {"continuation": 0.82, "mean_reversion": 0.05, "reversal": 0.03, "wait": 0.1},
  315. "scoped_state": {
  316. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "low"},
  317. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  318. "macro": {"bias": "bullish"},
  319. },
  320. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  321. "features_by_timeframe": {"1m": {"raw": {"price": 116.0}}},
  322. }
  323. wallet_state = {"inventory_state": "balanced", "rebalance_needed": False, "grid_ready": True, "base_ratio": 0.52, "quote_ratio": 0.48}
  324. strategies = [
  325. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 100.0}, "config": {"grid_step_pct": 0.05}},
  326. {"id": "trend-long", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {"trade_side": "buy"}},
  327. {"id": "trend-short", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {"trade_side": "sell"}},
  328. ]
  329. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  330. assert decision.mode == "act"
  331. assert decision.action == "replace_with_trend_follower"
  332. assert decision.target_strategy == "trend-long"
  333. def test_make_decision_marks_breakout_as_developing_under_partial_alignment():
  334. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  335. narrative = {
  336. "stance": "cautious_bullish",
  337. "confidence": 0.76,
  338. "opportunity_map": {"continuation": 0.62, "mean_reversion": 0.12, "reversal": 0.06, "wait": 0.2},
  339. "scoped_state": {
  340. "micro": {"impulse": "up", "trend_bias": "bullish", "reversal_risk": "low"},
  341. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  342. "macro": {"bias": "bullish"},
  343. },
  344. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  345. }
  346. wallet_state = {
  347. "inventory_state": "base_heavy",
  348. "rebalance_needed": True,
  349. "grid_ready": False,
  350. "base_ratio": 0.74,
  351. "quote_ratio": 0.26,
  352. }
  353. strategies = [
  354. {"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}}}},
  355. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  356. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  357. ]
  358. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  359. assert decision.action == "keep_grid"
  360. assert decision.payload["grid_breakout_pressure"]["phase"] == "developing"
  361. assert decision.reason_summary == "breakout pressure is developing, but grid can still work and should not be abandoned yet"
  362. def test_make_decision_argus_compression_stays_context_only():
  363. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  364. narrative = {
  365. "stance": "constructive_bullish",
  366. "confidence": 0.82,
  367. "opportunity_map": {"continuation": 0.82, "mean_reversion": 0.05, "reversal": 0.03, "wait": 0.1},
  368. "scoped_state": {
  369. "micro": {"impulse": "up", "trend_bias": "bullish", "reversal_risk": "low"},
  370. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  371. "macro": {"bias": "bullish"},
  372. },
  373. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  374. "argus_context": {
  375. "regime": "compression",
  376. "regime_confidence": 0.72,
  377. "regime_components": {"compression": 0.81},
  378. },
  379. "features_by_timeframe": {"1m": {"raw": {"price": 112.0}}},
  380. }
  381. wallet_state = {
  382. "inventory_state": "balanced",
  383. "rebalance_needed": False,
  384. "grid_ready": True,
  385. "base_ratio": 0.52,
  386. "quote_ratio": 0.48,
  387. }
  388. strategies = [
  389. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 100.0}, "config": {"grid_step_pct": 0.05}},
  390. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  391. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  392. ]
  393. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  394. assert decision.action == "keep_grid"
  395. assert decision.payload["grid_breakout_pressure"]["argus_compression_active"] is True
  396. assert decision.payload["grid_breakout_pressure"]["phase"] == "confirmed"
  397. assert decision.payload["argus_decision_context"]["compression_active"] is True
  398. def test_make_decision_promotes_developing_breakout_from_time_window_memory():
  399. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  400. narrative = {
  401. "generated_at": "2026-04-18T20:15:00+00:00",
  402. "stance": "cautious_bullish",
  403. "confidence": 0.76,
  404. "opportunity_map": {"continuation": 0.62, "mean_reversion": 0.12, "reversal": 0.06, "wait": 0.2},
  405. "scoped_state": {
  406. "micro": {"impulse": "up", "trend_bias": "bullish", "reversal_risk": "low"},
  407. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  408. "macro": {"bias": "bullish"},
  409. },
  410. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  411. }
  412. wallet_state = {
  413. "inventory_state": "base_heavy",
  414. "rebalance_needed": True,
  415. "grid_ready": False,
  416. "base_ratio": 0.74,
  417. "quote_ratio": 0.26,
  418. }
  419. strategies = [
  420. {"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}}}},
  421. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  422. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  423. ]
  424. history_window = {
  425. "window_seconds": 15 * 60,
  426. "recent_states": [
  427. {
  428. "created_at": "2026-04-18T20:06:00+00:00",
  429. "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"}}',
  430. },
  431. {
  432. "created_at": "2026-04-18T20:10:30+00:00",
  433. "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"}}',
  434. },
  435. ],
  436. }
  437. decision = make_decision(
  438. concern=concern,
  439. narrative_payload=narrative,
  440. wallet_state=wallet_state,
  441. strategies=strategies,
  442. history_window=history_window,
  443. )
  444. assert decision.payload["grid_breakout_pressure"]["phase"] == "confirmed"
  445. assert decision.payload["grid_breakout_pressure"]["time_window_memory"]["promoted_to_confirmed"] is True
  446. assert decision.payload["grid_breakout_pressure"]["time_window_memory"]["same_direction_seconds"] >= 540
  447. def test_make_decision_replaces_grid_with_trend_when_breakout_is_persistent_but_inventory_is_only_base_heavy():
  448. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  449. narrative = {
  450. "stance": "constructive_bullish",
  451. "confidence": 0.78,
  452. "opportunity_map": {"continuation": 0.72, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.15},
  453. "scoped_state": {
  454. "micro": {"impulse": "up", "trend_bias": "bullish"},
  455. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  456. "macro": {"bias": "bullish"},
  457. },
  458. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  459. }
  460. wallet_state = {
  461. "inventory_state": "base_heavy",
  462. "rebalance_needed": True,
  463. "grid_ready": False,
  464. "base_ratio": 0.64,
  465. "quote_ratio": 0.36,
  466. }
  467. strategies = [
  468. {"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}}}},
  469. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  470. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  471. ]
  472. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  473. assert decision.action == "keep_grid"
  474. assert decision.target_strategy == "grid-1"
  475. def test_make_decision_prefers_active_grid_over_observe_trend_as_current_primary():
  476. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  477. narrative = {
  478. "stance": "constructive_bullish",
  479. "confidence": 0.72,
  480. "opportunity_map": {"continuation": 0.75, "mean_reversion": 0.1, "reversal": 0.05, "wait": 0.1},
  481. }
  482. wallet_state = {
  483. "inventory_state": "base_heavy",
  484. "rebalance_needed": True,
  485. "grid_ready": False,
  486. "base_ratio": 0.81,
  487. "quote_ratio": 0.19,
  488. }
  489. strategies = [
  490. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
  491. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  492. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  493. ]
  494. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  495. assert decision.action == "keep_grid"
  496. assert decision.target_strategy == "grid-1"
  497. def test_make_decision_prefers_trend_over_rebalancer_on_bullish_breakout_with_depleted_base():
  498. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  499. narrative = {
  500. "stance": "constructive_bullish",
  501. "confidence": 0.9,
  502. "opportunity_map": {"continuation": 0.85, "mean_reversion": 0.05, "reversal": 0.02, "wait": 0.08},
  503. "scoped_state": {
  504. "micro": {"impulse": "up", "trend_bias": "bullish"},
  505. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  506. "macro": {"bias": "bullish"},
  507. },
  508. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  509. }
  510. wallet_state = {
  511. "inventory_state": "critically_unbalanced",
  512. "rebalance_needed": True,
  513. "grid_ready": False,
  514. "base_ratio": 0.0,
  515. "quote_ratio": 1.0,
  516. }
  517. strategies = [
  518. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  519. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  520. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  521. ]
  522. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  523. assert decision.action == "replace_with_exposure_protector"
  524. assert decision.target_strategy == "protect-1"
  525. def test_make_decision_replaces_grid_when_next_sell_is_close_but_confirmed_trend_handoff_is_ready():
  526. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  527. narrative = {
  528. "stance": "constructive_bullish",
  529. "confidence": 0.9,
  530. "opportunity_map": {"continuation": 0.85, "mean_reversion": 0.05, "reversal": 0.02, "wait": 0.08},
  531. "features_by_timeframe": {
  532. "1m": {"raw": {"price": 1.4374, "atr_percent": 0.11}},
  533. },
  534. "scoped_state": {
  535. "micro": {"impulse": "up", "trend_bias": "bullish"},
  536. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  537. "macro": {"bias": "bullish"},
  538. },
  539. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  540. }
  541. wallet_state = {
  542. "inventory_state": "base_heavy",
  543. "rebalance_needed": True,
  544. "grid_ready": False,
  545. "base_ratio": 0.65,
  546. "quote_ratio": 0.35,
  547. }
  548. strategies = [
  549. {
  550. "id": "grid-1",
  551. "strategy_type": "grid_trader",
  552. "mode": "active",
  553. "account_id": "a1",
  554. "market_symbol": "xrpusd",
  555. "state": {
  556. "last_price": 1.4374,
  557. "center_price": 1.24,
  558. "orders": [
  559. {"side": "sell", "status": "open", "price": "1.43956", "amount": "7"},
  560. {"side": "buy", "status": "open", "price": "1.42523", "amount": "7"},
  561. ],
  562. },
  563. "config": {"grid_step_pct": 0.05},
  564. },
  565. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
  566. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  567. ]
  568. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  569. assert decision.action == "replace_with_trend_follower"
  570. assert decision.target_strategy == "trend-1"
  571. assert decision.payload["grid_fill_context"]["near_fill_side"] == "sell"
  572. def test_make_decision_replaces_grid_when_time_promoted_confirmation_clears_lower_handoff_threshold():
  573. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  574. narrative = {
  575. "generated_at": "2026-04-18T20:15:00+00:00",
  576. "stance": "breakout_watch",
  577. "confidence": 0.78,
  578. "opportunity_map": {"continuation": 0.7, "mean_reversion": 0.1, "reversal": 0.04, "wait": 0.16},
  579. "features_by_timeframe": {
  580. "1m": {"raw": {"price": 111.0, "atr_percent": 0.35}},
  581. },
  582. "scoped_state": {
  583. "micro": {"impulse": "up", "trend_bias": "bullish", "reversal_risk": "low"},
  584. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  585. "macro": {"bias": "bullish"},
  586. },
  587. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  588. }
  589. wallet_state = {
  590. "inventory_state": "base_heavy",
  591. "rebalance_needed": True,
  592. "grid_ready": False,
  593. "base_ratio": 0.66,
  594. "quote_ratio": 0.34,
  595. }
  596. strategies = [
  597. {
  598. "id": "grid-1",
  599. "strategy_type": "grid_trader",
  600. "mode": "active",
  601. "account_id": "a1",
  602. "market_symbol": "xrpusd",
  603. "state": {"last_price": 111.0, "center_price": 100.0},
  604. "config": {"grid_step_pct": 0.05},
  605. },
  606. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
  607. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  608. ]
  609. history_window = {
  610. "window_seconds": 15 * 60,
  611. "recent_states": [
  612. {
  613. "created_at": "2026-04-18T20:06:00+00:00",
  614. "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"}}',
  615. },
  616. {
  617. "created_at": "2026-04-18T20:10:30+00:00",
  618. "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"}}',
  619. },
  620. ],
  621. }
  622. decision = make_decision(
  623. concern=concern,
  624. narrative_payload=narrative,
  625. wallet_state=wallet_state,
  626. strategies=strategies,
  627. history_window=history_window,
  628. )
  629. assert decision.action == "replace_with_trend_follower"
  630. assert decision.target_strategy == "trend-1"
  631. assert decision.payload["grid_breakout_pressure"]["time_window_memory"]["promoted_to_confirmed"] is True
  632. def test_normalize_strategy_snapshot_uses_live_report_contract_and_supervision():
  633. normalized = normalize_strategy_snapshot({
  634. "id": "grid-1",
  635. "strategy_type": "grid_trader",
  636. "mode": "active",
  637. "account_id": "a1",
  638. "report": {
  639. "fit": {
  640. "role": "primary",
  641. "inventory_behavior": "balanced",
  642. "safe_when_unbalanced": False,
  643. "can_run_with": ["exposure_protector"],
  644. },
  645. "state": {"last_action": "hold", "open_order_count": 12},
  646. "supervision": {"inventory_pressure": "base_heavy", "capacity_available": False, "side_capacity": {"buy": True, "sell": False}, "degraded": False},
  647. },
  648. })
  649. assert normalized["contract"]["inventory_behavior"] == "balanced"
  650. assert normalized["supervision"]["capacity_available"] is False
  651. assert normalized["open_order_count"] == 12
  652. def test_make_decision_keeps_trend_during_directional_regime_even_if_wallet_is_skewed():
  653. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  654. narrative = {
  655. "stance": "constructive_bullish",
  656. "confidence": 0.7,
  657. "opportunity_map": {"continuation": 0.8, "mean_reversion": 0.1, "reversal": 0.05, "wait": 0.05},
  658. }
  659. wallet_state = {
  660. "inventory_state": "critically_unbalanced",
  661. "rebalance_needed": True,
  662. "grid_ready": False,
  663. "base_ratio": 0.88,
  664. "quote_ratio": 0.12,
  665. }
  666. strategies = [
  667. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  668. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  669. ]
  670. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  671. assert decision.mode == "observe"
  672. assert decision.action == "keep_trend"
  673. assert decision.target_strategy == "trend-1"
  674. def test_make_decision_replaces_trend_with_rebalancer_after_trend_cools_and_wallet_needs_repair():
  675. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  676. narrative = {
  677. "stance": "neutral_rotational",
  678. "confidence": 0.65,
  679. "opportunity_map": {"continuation": 0.15, "mean_reversion": 0.25, "reversal": 0.2, "wait": 0.4},
  680. }
  681. wallet_state = {
  682. "inventory_state": "critically_unbalanced",
  683. "rebalance_needed": True,
  684. "grid_ready": False,
  685. "base_ratio": 0.88,
  686. "quote_ratio": 0.12,
  687. }
  688. strategies = [
  689. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  690. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  691. ]
  692. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  693. assert decision.mode == "act"
  694. assert decision.action == "replace_with_exposure_protector"
  695. assert decision.target_strategy == "protect-1"
  696. def test_make_decision_replaces_trend_with_rebalancer_on_edge_cooling_even_before_full_rotational_stance():
  697. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  698. narrative = {
  699. "stance": "constructive_bullish",
  700. "confidence": 0.74,
  701. "opportunity_map": {"continuation": 0.58, "mean_reversion": 0.12, "reversal": 0.08, "wait": 0.22},
  702. "scoped_state": {
  703. "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "near_upper_band"},
  704. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  705. "macro": {"bias": "bullish"},
  706. },
  707. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  708. }
  709. wallet_state = {
  710. "inventory_state": "base_heavy",
  711. "rebalance_needed": True,
  712. "grid_ready": False,
  713. "base_ratio": 0.74,
  714. "quote_ratio": 0.26,
  715. }
  716. strategies = [
  717. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  718. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  719. ]
  720. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  721. assert decision.mode == "act"
  722. assert decision.action == "replace_with_exposure_protector"
  723. assert decision.target_strategy == "protect-1"
  724. def test_make_decision_replaces_trend_with_rebalancer_when_micro_reversal_risk_spikes():
  725. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  726. narrative = {
  727. "stance": "constructive_bullish",
  728. "confidence": 0.76,
  729. "opportunity_map": {"continuation": 0.62, "mean_reversion": 0.1, "reversal": 0.18, "wait": 0.1},
  730. "scoped_state": {
  731. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "high"},
  732. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  733. "macro": {"bias": "bullish"},
  734. },
  735. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  736. }
  737. wallet_state = {
  738. "inventory_state": "base_heavy",
  739. "rebalance_needed": True,
  740. "grid_ready": False,
  741. "base_ratio": 0.72,
  742. "quote_ratio": 0.28,
  743. }
  744. strategies = [
  745. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  746. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  747. ]
  748. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  749. assert decision.mode == "act"
  750. assert decision.action == "replace_with_exposure_protector"
  751. assert decision.target_strategy == "protect-1"
  752. def test_make_decision_replaces_rebalancer_with_grid_when_balanced_and_rotational():
  753. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  754. narrative = {
  755. "stance": "neutral_rotational",
  756. "confidence": 0.68,
  757. "opportunity_map": {"continuation": 0.15, "mean_reversion": 0.72, "reversal": 0.05, "wait": 0.08},
  758. }
  759. wallet_state = {
  760. "inventory_state": "balanced",
  761. "rebalance_needed": False,
  762. "grid_ready": True,
  763. "base_ratio": 0.49,
  764. "quote_ratio": 0.51,
  765. }
  766. strategies = [
  767. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  768. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  769. ]
  770. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  771. assert decision.mode == "act"
  772. assert decision.action == "replace_with_grid"
  773. assert decision.target_strategy == "grid-1"
  774. def test_make_decision_replaces_rebalancer_with_grid_when_within_tolerance_even_before_perfect_balance():
  775. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  776. narrative = {
  777. "stance": "neutral_rotational",
  778. "confidence": 0.7,
  779. "opportunity_map": {"continuation": 0.18, "mean_reversion": 0.68, "reversal": 0.05, "wait": 0.09},
  780. }
  781. wallet_state = {
  782. "inventory_state": "base_heavy",
  783. "rebalance_needed": True,
  784. "grid_ready": True,
  785. "base_ratio": 0.71,
  786. "quote_ratio": 0.29,
  787. }
  788. strategies = [
  789. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  790. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  791. ]
  792. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  793. assert decision.mode == "act"
  794. assert decision.action == "replace_with_grid"
  795. assert decision.target_strategy == "grid-1"
  796. def test_make_decision_replaces_rebalancer_with_grid_when_trend_is_directional_but_not_sustained():
  797. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  798. narrative = {
  799. "stance": "constructive_bullish",
  800. "confidence": 0.72,
  801. "opportunity_map": {"continuation": 0.4, "mean_reversion": 0.4, "reversal": 0.08, "wait": 0.12},
  802. "scoped_state": {
  803. "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "centered", "reversal_risk": "low"},
  804. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  805. "macro": {"bias": "bullish"},
  806. },
  807. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  808. }
  809. wallet_state = {
  810. "inventory_state": "balanced",
  811. "rebalance_needed": False,
  812. "grid_ready": True,
  813. "base_ratio": 0.51,
  814. "quote_ratio": 0.49,
  815. }
  816. strategies = [
  817. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  818. {"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}}},
  819. {"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}}},
  820. ]
  821. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  822. assert decision.mode == "act"
  823. assert decision.action == "replace_with_grid"
  824. assert decision.target_strategy == "grid-1"
  825. def test_make_decision_replaces_rebalancer_with_grid_when_micro_easing_restores_harvestability():
  826. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  827. narrative = {
  828. "stance": "constructive_bearish",
  829. "confidence": 0.73,
  830. "opportunity_map": {"continuation": 0.42, "mean_reversion": 0.38, "reversal": 0.08, "wait": 0.12},
  831. "scoped_state": {
  832. "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "centered", "reversal_risk": "low"},
  833. "meso": {"structure": "trend_continuation", "momentum_bias": "bearish"},
  834. "macro": {"bias": "bearish"},
  835. },
  836. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  837. "features_by_timeframe": {
  838. "1m": {
  839. "raw": {"price": 1.01, "atr_percent": 0.42},
  840. "volatility": {"bollinger_width_pct": 1.35},
  841. },
  842. },
  843. }
  844. wallet_state = {
  845. "inventory_state": "quote_heavy",
  846. "rebalance_needed": True,
  847. "grid_ready": False,
  848. "base_ratio": 0.29,
  849. "quote_ratio": 0.71,
  850. }
  851. strategies = [
  852. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  853. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {"grid_step_pct": 0.005}},
  854. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  855. ]
  856. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  857. assert decision.mode == "act"
  858. assert decision.action == "replace_with_grid"
  859. assert decision.target_strategy == "grid-1"
  860. assert decision.payload["decision_audit"]["tactical_easing"] is True
  861. assert decision.payload["decision_audit"]["grid_harvestable_now"] is True
  862. assert decision.payload["decision_audit"]["rebalancer_release_ready"] is True
  863. def test_make_decision_replaces_rebalancer_with_grid_near_local_bottom_when_noise_exceeds_grid_size():
  864. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  865. narrative = {
  866. "stance": "cautious_bearish",
  867. "confidence": 0.69,
  868. "opportunity_map": {"continuation": 0.34, "mean_reversion": 0.44, "reversal": 0.1, "wait": 0.12},
  869. "scoped_state": {
  870. "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "near_lower_band", "reversal_risk": "medium"},
  871. "meso": {"structure": "trend_continuation", "momentum_bias": "bearish"},
  872. "macro": {"bias": "bearish"},
  873. },
  874. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  875. "features_by_timeframe": {
  876. "1m": {
  877. "raw": {"price": 0.992, "atr_percent": 0.48},
  878. "volatility": {"bollinger_width_pct": 1.6},
  879. },
  880. },
  881. }
  882. wallet_state = {
  883. "inventory_state": "quote_heavy",
  884. "rebalance_needed": True,
  885. "grid_ready": False,
  886. "base_ratio": 0.31,
  887. "quote_ratio": 0.69,
  888. }
  889. strategies = [
  890. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  891. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {"grid_step_pct": 0.005}},
  892. ]
  893. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  894. assert decision.mode == "act"
  895. assert decision.action == "replace_with_grid"
  896. assert decision.target_strategy == "grid-1"
  897. assert decision.payload["decision_audit"]["pullback_to_grid_ratio"] is not None
  898. assert decision.payload["decision_audit"]["pullback_to_grid_ratio"] > 2.0
  899. def test_make_decision_replaces_grid_with_trend_when_pullbacks_are_too_shallow_for_grid_step():
  900. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  901. narrative = {
  902. "generated_at": "2026-04-19T19:05:00+00:00",
  903. "stance": "constructive_bullish",
  904. "confidence": 0.88,
  905. "opportunity_map": {"continuation": 0.84, "mean_reversion": 0.05, "reversal": 0.03, "wait": 0.08},
  906. "scoped_state": {
  907. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "low"},
  908. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  909. "macro": {"bias": "bullish"},
  910. },
  911. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  912. "features_by_timeframe": {
  913. "1m": {
  914. "raw": {"price": 104.2, "atr_percent": 0.11},
  915. "volatility": {"bollinger_width_pct": 0.24},
  916. },
  917. },
  918. }
  919. wallet_state = {
  920. "inventory_state": "base_heavy",
  921. "rebalance_needed": True,
  922. "grid_ready": False,
  923. "base_ratio": 0.67,
  924. "quote_ratio": 0.33,
  925. }
  926. strategies = [
  927. {
  928. "id": "grid-1",
  929. "strategy_type": "grid_trader",
  930. "mode": "active",
  931. "account_id": "a1",
  932. "market_symbol": "xrpusd",
  933. "state": {
  934. "last_price": 104.2,
  935. "center_price": 100.0,
  936. "orders": [
  937. {"side": "sell", "status": "open", "price": "104.7", "amount": "5"},
  938. {"side": "buy", "status": "open", "price": "103.7", "amount": "5"},
  939. ],
  940. },
  941. "config": {"grid_step_pct": 0.005},
  942. },
  943. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
  944. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  945. ]
  946. history_window = {
  947. "window_seconds": 15 * 60,
  948. "recent_states": [
  949. {
  950. "created_at": "2026-04-19T18:55:00+00:00",
  951. "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"}}',
  952. },
  953. {
  954. "created_at": "2026-04-19T19:00:30+00:00",
  955. "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"}}',
  956. },
  957. ],
  958. }
  959. decision = make_decision(
  960. concern=concern,
  961. narrative_payload=narrative,
  962. wallet_state=wallet_state,
  963. strategies=strategies,
  964. history_window=history_window,
  965. )
  966. assert decision.mode == "act"
  967. assert decision.action == "replace_with_trend_follower"
  968. assert decision.target_strategy == "trend-1"
  969. assert decision.payload["decision_audit"]["pullback_to_grid_ratio"] < 1.0
  970. assert decision.payload["decision_audit"]["trend_following_pressure"] is True
  971. def test_make_decision_replaces_rebalancer_with_trend_when_breakout_is_still_strong():
  972. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  973. narrative = {
  974. "stance": "constructive_bullish",
  975. "confidence": 0.84,
  976. "opportunity_map": {"continuation": 0.82, "mean_reversion": 0.08, "reversal": 0.03, "wait": 0.07},
  977. "scoped_state": {
  978. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "low"},
  979. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  980. "macro": {"bias": "bullish"},
  981. },
  982. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  983. }
  984. wallet_state = {
  985. "inventory_state": "base_heavy",
  986. "rebalance_needed": True,
  987. "grid_ready": False,
  988. "base_ratio": 0.74,
  989. "quote_ratio": 0.26,
  990. }
  991. strategies = [
  992. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  993. {"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}}},
  994. {"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}}},
  995. ]
  996. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  997. assert decision.mode == "observe"
  998. assert decision.action == "keep_rebalancer"
  999. assert decision.target_strategy == "protect-1"