test_decision_engine.py 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829
  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_critically_unbalanced():
  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"] == "critically_unbalanced"
  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_score_strategy_fit_penalizes_grid_when_wallet_unbalanced():
  28. strategy = normalize_strategy_snapshot({
  29. "id": "grid-1",
  30. "strategy_type": "grid_trader",
  31. "mode": "active",
  32. "account_id": "a1",
  33. "state": {},
  34. "config": {},
  35. })
  36. narrative = {"stance": "constructive_bullish", "opportunity_map": {"continuation": 0.7, "mean_reversion": 0.1, "reversal": 0.1, "wait": 0.1}}
  37. wallet_state = {"inventory_state": "critically_unbalanced", "rebalance_needed": True}
  38. fit = score_strategy_fit(strategy=strategy, narrative=narrative, wallet_state=wallet_state)
  39. assert fit["score"] < 0
  40. assert any("grid" in block or "wallet" in block for block in fit["blocks"])
  41. def test_score_strategy_fit_rewards_trend_when_breakout_is_confirmed():
  42. strategy = normalize_strategy_snapshot({
  43. "id": "trend-1",
  44. "strategy_type": "trend_follower",
  45. "mode": "off",
  46. "account_id": "a1",
  47. "state": {},
  48. "config": {},
  49. })
  50. base_narrative = {
  51. "stance": "constructive_bullish",
  52. "opportunity_map": {"continuation": 0.72, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.15},
  53. "grid_breakout_pressure": {"phase": "developing"},
  54. }
  55. confirmed_narrative = {
  56. **base_narrative,
  57. "grid_breakout_pressure": {"phase": "confirmed", "persistent": True},
  58. }
  59. wallet_state = {"inventory_state": "balanced", "rebalance_needed": False}
  60. base_fit = score_strategy_fit(strategy=strategy, narrative=base_narrative, wallet_state=wallet_state)
  61. confirmed_fit = score_strategy_fit(strategy=strategy, narrative=confirmed_narrative, wallet_state=wallet_state)
  62. assert confirmed_fit["score"] > base_fit["score"]
  63. assert any("confirmed breakout" in reason for reason in confirmed_fit["reasons"])
  64. def test_assess_wallet_state_counts_reserved_orders_in_effective_inventory():
  65. concern = {"account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  66. account_info = {
  67. "balances": [
  68. {"asset_code": "XRP", "available": 0},
  69. {"asset_code": "USD", "available": 0},
  70. ]
  71. }
  72. strategies = [
  73. {
  74. "id": "grid-1",
  75. "strategy_type": "grid_trader",
  76. "mode": "active",
  77. "account_id": "a1",
  78. "market_symbol": "xrpusd",
  79. "state": {
  80. "orders": [
  81. {"side": "sell", "status": "open", "amount": "10", "price": "1.50"},
  82. {"side": "buy", "status": "open", "amount": "10", "price": "1.40"},
  83. ]
  84. },
  85. }
  86. ]
  87. wallet = assess_wallet_state(account_info=account_info, concern=concern, price=1.45, strategies=strategies)
  88. assert wallet["inventory_state"] == "balanced"
  89. assert wallet["base_reserved"] == 10.0
  90. assert wallet["quote_reserved"] == 14.0
  91. assert wallet["base_effective"] == 10.0
  92. assert wallet["quote_effective"] == 14.0
  93. def test_make_decision_keeps_grid_when_imbalance_is_manageable():
  94. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  95. narrative = {
  96. "stance": "constructive_bullish",
  97. "confidence": 0.72,
  98. "opportunity_map": {"continuation": 0.75, "mean_reversion": 0.1, "reversal": 0.05, "wait": 0.1},
  99. }
  100. wallet_state = {
  101. "inventory_state": "base_heavy",
  102. "rebalance_needed": True,
  103. "grid_ready": False,
  104. "base_ratio": 0.8,
  105. "quote_ratio": 0.2,
  106. }
  107. strategies = [
  108. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  109. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  110. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  111. ]
  112. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  113. assert decision.mode == "observe"
  114. assert decision.action == "keep_grid"
  115. assert decision.target_strategy == "grid-1"
  116. def test_make_decision_does_not_replace_grid_with_rebalancer_only_because_grid_mentions_handoff_readiness():
  117. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  118. narrative = {
  119. "stance": "cautious_bullish",
  120. "confidence": 0.74,
  121. "opportunity_map": {"continuation": 0.5, "mean_reversion": 0.25, "reversal": 0.05, "wait": 0.2},
  122. "scoped_state": {
  123. "micro": {"impulse": "mixed", "trend_bias": "mixed"},
  124. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  125. "macro": {"bias": "bullish"},
  126. },
  127. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  128. }
  129. wallet_state = {
  130. "inventory_state": "base_heavy",
  131. "rebalance_needed": True,
  132. "grid_ready": False,
  133. "base_ratio": 0.64,
  134. "quote_ratio": 0.36,
  135. }
  136. strategies = [
  137. {"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}}}},
  138. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  139. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  140. ]
  141. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  142. assert decision.action == "keep_grid"
  143. assert decision.target_strategy == "grid-1"
  144. def test_make_decision_replaces_grid_when_breakout_pressure_is_persistent():
  145. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  146. narrative = {
  147. "stance": "constructive_bullish",
  148. "confidence": 0.78,
  149. "opportunity_map": {"continuation": 0.72, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.15},
  150. "scoped_state": {
  151. "micro": {"impulse": "up", "trend_bias": "bullish"},
  152. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  153. "macro": {"bias": "bullish"},
  154. },
  155. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  156. }
  157. wallet_state = {
  158. "inventory_state": "critically_unbalanced",
  159. "rebalance_needed": True,
  160. "grid_ready": False,
  161. "base_ratio": 0.88,
  162. "quote_ratio": 0.12,
  163. }
  164. strategies = [
  165. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  166. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  167. ]
  168. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  169. assert decision.action == "replace_with_exposure_protector"
  170. assert decision.target_strategy == "protect-1"
  171. def test_make_decision_keeps_grid_when_critically_unbalanced_but_grid_still_has_working_side_capacity():
  172. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  173. narrative = {
  174. "stance": "cautious_bullish",
  175. "confidence": 0.7,
  176. "opportunity_map": {"continuation": 0.55, "mean_reversion": 0.2, "reversal": 0.05, "wait": 0.2},
  177. "scoped_state": {
  178. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "upper_half", "reversal_risk": "low"},
  179. "meso": {"structure": "range", "momentum_bias": "bullish"},
  180. "macro": {"bias": "bullish"},
  181. },
  182. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  183. "features_by_timeframe": {"1m": {"raw": {"price": 1.4374}}},
  184. }
  185. wallet_state = {
  186. "inventory_state": "critically_unbalanced",
  187. "rebalance_needed": True,
  188. "grid_ready": False,
  189. "base_ratio": 0.86,
  190. "quote_ratio": 0.14,
  191. }
  192. strategies = [
  193. {
  194. "id": "grid-1",
  195. "strategy_type": "grid_trader",
  196. "mode": "active",
  197. "account_id": "a1",
  198. "state": {
  199. "last_price": 1.4374,
  200. "open_order_count": 4,
  201. "orders": [
  202. {"side": "sell", "status": "open", "price": "1.43956", "amount": "7"},
  203. {"side": "sell", "status": "open", "price": "1.44500", "amount": "7"},
  204. ],
  205. },
  206. "config": {},
  207. "report": {"supervision": {"capacity_available": False, "side_capacity": {"buy": False, "sell": True}, "inventory_pressure": "critical", "degraded": False}},
  208. },
  209. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  210. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  211. ]
  212. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  213. assert decision.mode == "observe"
  214. assert decision.action == "keep_grid"
  215. assert decision.target_strategy == "grid-1"
  216. def test_make_decision_keeps_grid_when_trend_has_only_eaten_two_levels():
  217. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  218. narrative = {
  219. "stance": "constructive_bullish",
  220. "confidence": 0.78,
  221. "opportunity_map": {"continuation": 0.72, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.15},
  222. "scoped_state": {
  223. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "upper_half", "reversal_risk": "low"},
  224. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  225. "macro": {"bias": "bullish"},
  226. },
  227. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  228. "features_by_timeframe": {"1m": {"raw": {"price": 110.0}}},
  229. }
  230. wallet_state = {
  231. "inventory_state": "balanced",
  232. "rebalance_needed": False,
  233. "grid_ready": True,
  234. "base_ratio": 0.52,
  235. "quote_ratio": 0.48,
  236. }
  237. strategies = [
  238. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 100.0}, "config": {"grid_step_pct": 0.05}},
  239. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  240. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  241. ]
  242. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  243. assert decision.mode == "warn"
  244. assert decision.action == "keep_grid"
  245. assert decision.target_strategy == "grid-1"
  246. def test_make_decision_replaces_grid_when_third_level_is_sustained():
  247. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  248. narrative = {
  249. "stance": "constructive_bullish",
  250. "confidence": 0.82,
  251. "opportunity_map": {"continuation": 0.82, "mean_reversion": 0.05, "reversal": 0.03, "wait": 0.1},
  252. "scoped_state": {
  253. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "low"},
  254. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  255. "macro": {"bias": "bullish"},
  256. },
  257. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  258. "features_by_timeframe": {"1m": {"raw": {"price": 116.0}}},
  259. }
  260. wallet_state = {
  261. "inventory_state": "balanced",
  262. "rebalance_needed": False,
  263. "grid_ready": True,
  264. "base_ratio": 0.52,
  265. "quote_ratio": 0.48,
  266. }
  267. strategies = [
  268. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 100.0}, "config": {"grid_step_pct": 0.05}},
  269. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  270. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  271. ]
  272. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  273. assert decision.mode == "act"
  274. assert decision.action == "replace_with_trend_follower"
  275. assert decision.target_strategy == "trend-1"
  276. assert decision.payload["grid_breakout_pressure"]["phase"] == "confirmed"
  277. def test_make_decision_marks_breakout_as_developing_under_partial_alignment():
  278. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  279. narrative = {
  280. "stance": "cautious_bullish",
  281. "confidence": 0.76,
  282. "opportunity_map": {"continuation": 0.62, "mean_reversion": 0.12, "reversal": 0.06, "wait": 0.2},
  283. "scoped_state": {
  284. "micro": {"impulse": "up", "trend_bias": "bullish", "reversal_risk": "low"},
  285. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  286. "macro": {"bias": "bullish"},
  287. },
  288. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  289. }
  290. wallet_state = {
  291. "inventory_state": "base_heavy",
  292. "rebalance_needed": True,
  293. "grid_ready": False,
  294. "base_ratio": 0.74,
  295. "quote_ratio": 0.26,
  296. }
  297. strategies = [
  298. {"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}}}},
  299. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  300. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  301. ]
  302. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  303. assert decision.action == "keep_grid"
  304. assert decision.payload["grid_breakout_pressure"]["phase"] == "developing"
  305. assert decision.reason_summary == "breakout pressure is developing, but grid can still work and should not be abandoned yet"
  306. def test_make_decision_argus_compression_stays_context_only():
  307. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  308. narrative = {
  309. "stance": "constructive_bullish",
  310. "confidence": 0.82,
  311. "opportunity_map": {"continuation": 0.82, "mean_reversion": 0.05, "reversal": 0.03, "wait": 0.1},
  312. "scoped_state": {
  313. "micro": {"impulse": "up", "trend_bias": "bullish", "reversal_risk": "low"},
  314. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  315. "macro": {"bias": "bullish"},
  316. },
  317. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  318. "argus_context": {
  319. "regime": "compression",
  320. "regime_confidence": 0.72,
  321. "regime_components": {"compression": 0.81},
  322. },
  323. "features_by_timeframe": {"1m": {"raw": {"price": 112.0}}},
  324. }
  325. wallet_state = {
  326. "inventory_state": "balanced",
  327. "rebalance_needed": False,
  328. "grid_ready": True,
  329. "base_ratio": 0.52,
  330. "quote_ratio": 0.48,
  331. }
  332. strategies = [
  333. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 100.0}, "config": {"grid_step_pct": 0.05}},
  334. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  335. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  336. ]
  337. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  338. assert decision.action == "keep_grid"
  339. assert decision.payload["grid_breakout_pressure"]["argus_compression_active"] is True
  340. assert decision.payload["grid_breakout_pressure"]["phase"] == "confirmed"
  341. assert decision.payload["argus_decision_context"]["compression_active"] is True
  342. def test_make_decision_promotes_developing_breakout_from_time_window_memory():
  343. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  344. narrative = {
  345. "generated_at": "2026-04-18T20:15:00+00:00",
  346. "stance": "cautious_bullish",
  347. "confidence": 0.76,
  348. "opportunity_map": {"continuation": 0.62, "mean_reversion": 0.12, "reversal": 0.06, "wait": 0.2},
  349. "scoped_state": {
  350. "micro": {"impulse": "up", "trend_bias": "bullish", "reversal_risk": "low"},
  351. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  352. "macro": {"bias": "bullish"},
  353. },
  354. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  355. }
  356. wallet_state = {
  357. "inventory_state": "base_heavy",
  358. "rebalance_needed": True,
  359. "grid_ready": False,
  360. "base_ratio": 0.74,
  361. "quote_ratio": 0.26,
  362. }
  363. strategies = [
  364. {"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}}}},
  365. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  366. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  367. ]
  368. history_window = {
  369. "window_seconds": 15 * 60,
  370. "recent_states": [
  371. {
  372. "created_at": "2026-04-18T20:06:00+00:00",
  373. "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"}}',
  374. },
  375. {
  376. "created_at": "2026-04-18T20:10:30+00:00",
  377. "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"}}',
  378. },
  379. ],
  380. }
  381. decision = make_decision(
  382. concern=concern,
  383. narrative_payload=narrative,
  384. wallet_state=wallet_state,
  385. strategies=strategies,
  386. history_window=history_window,
  387. )
  388. assert decision.payload["grid_breakout_pressure"]["phase"] == "confirmed"
  389. assert decision.payload["grid_breakout_pressure"]["time_window_memory"]["promoted_to_confirmed"] is True
  390. assert decision.payload["grid_breakout_pressure"]["time_window_memory"]["same_direction_seconds"] >= 540
  391. def test_make_decision_replaces_grid_with_trend_when_breakout_is_persistent_but_inventory_is_only_base_heavy():
  392. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  393. narrative = {
  394. "stance": "constructive_bullish",
  395. "confidence": 0.78,
  396. "opportunity_map": {"continuation": 0.72, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.15},
  397. "scoped_state": {
  398. "micro": {"impulse": "up", "trend_bias": "bullish"},
  399. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  400. "macro": {"bias": "bullish"},
  401. },
  402. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  403. }
  404. wallet_state = {
  405. "inventory_state": "base_heavy",
  406. "rebalance_needed": True,
  407. "grid_ready": False,
  408. "base_ratio": 0.64,
  409. "quote_ratio": 0.36,
  410. }
  411. strategies = [
  412. {"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}}}},
  413. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  414. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  415. ]
  416. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  417. assert decision.action == "keep_grid"
  418. assert decision.target_strategy == "grid-1"
  419. def test_make_decision_prefers_active_grid_over_observe_trend_as_current_primary():
  420. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  421. narrative = {
  422. "stance": "constructive_bullish",
  423. "confidence": 0.72,
  424. "opportunity_map": {"continuation": 0.75, "mean_reversion": 0.1, "reversal": 0.05, "wait": 0.1},
  425. }
  426. wallet_state = {
  427. "inventory_state": "base_heavy",
  428. "rebalance_needed": True,
  429. "grid_ready": False,
  430. "base_ratio": 0.81,
  431. "quote_ratio": 0.19,
  432. }
  433. strategies = [
  434. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
  435. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  436. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  437. ]
  438. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  439. assert decision.action == "keep_grid"
  440. assert decision.target_strategy == "grid-1"
  441. def test_make_decision_prefers_trend_over_rebalancer_on_bullish_breakout_with_depleted_base():
  442. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  443. narrative = {
  444. "stance": "constructive_bullish",
  445. "confidence": 0.9,
  446. "opportunity_map": {"continuation": 0.85, "mean_reversion": 0.05, "reversal": 0.02, "wait": 0.08},
  447. "scoped_state": {
  448. "micro": {"impulse": "up", "trend_bias": "bullish"},
  449. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  450. "macro": {"bias": "bullish"},
  451. },
  452. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  453. }
  454. wallet_state = {
  455. "inventory_state": "critically_unbalanced",
  456. "rebalance_needed": True,
  457. "grid_ready": False,
  458. "base_ratio": 0.0,
  459. "quote_ratio": 1.0,
  460. }
  461. strategies = [
  462. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  463. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  464. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  465. ]
  466. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  467. assert decision.action == "replace_with_exposure_protector"
  468. assert decision.target_strategy == "protect-1"
  469. def test_make_decision_replaces_grid_when_next_sell_is_close_but_confirmed_trend_handoff_is_ready():
  470. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  471. narrative = {
  472. "stance": "constructive_bullish",
  473. "confidence": 0.9,
  474. "opportunity_map": {"continuation": 0.85, "mean_reversion": 0.05, "reversal": 0.02, "wait": 0.08},
  475. "features_by_timeframe": {
  476. "1m": {"raw": {"price": 1.4374, "atr_percent": 0.11}},
  477. },
  478. "scoped_state": {
  479. "micro": {"impulse": "up", "trend_bias": "bullish"},
  480. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  481. "macro": {"bias": "bullish"},
  482. },
  483. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  484. }
  485. wallet_state = {
  486. "inventory_state": "base_heavy",
  487. "rebalance_needed": True,
  488. "grid_ready": False,
  489. "base_ratio": 0.65,
  490. "quote_ratio": 0.35,
  491. }
  492. strategies = [
  493. {
  494. "id": "grid-1",
  495. "strategy_type": "grid_trader",
  496. "mode": "active",
  497. "account_id": "a1",
  498. "market_symbol": "xrpusd",
  499. "state": {
  500. "last_price": 1.4374,
  501. "center_price": 1.24,
  502. "orders": [
  503. {"side": "sell", "status": "open", "price": "1.43956", "amount": "7"},
  504. {"side": "buy", "status": "open", "price": "1.42523", "amount": "7"},
  505. ],
  506. },
  507. "config": {"grid_step_pct": 0.05},
  508. },
  509. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
  510. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  511. ]
  512. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  513. assert decision.action == "replace_with_trend_follower"
  514. assert decision.target_strategy == "trend-1"
  515. assert decision.payload["grid_fill_context"]["near_fill_side"] == "sell"
  516. def test_make_decision_replaces_grid_when_time_promoted_confirmation_clears_lower_handoff_threshold():
  517. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  518. narrative = {
  519. "generated_at": "2026-04-18T20:15:00+00:00",
  520. "stance": "breakout_watch",
  521. "confidence": 0.78,
  522. "opportunity_map": {"continuation": 0.7, "mean_reversion": 0.1, "reversal": 0.04, "wait": 0.16},
  523. "features_by_timeframe": {
  524. "1m": {"raw": {"price": 111.0, "atr_percent": 0.35}},
  525. },
  526. "scoped_state": {
  527. "micro": {"impulse": "up", "trend_bias": "bullish", "reversal_risk": "low"},
  528. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  529. "macro": {"bias": "bullish"},
  530. },
  531. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  532. }
  533. wallet_state = {
  534. "inventory_state": "base_heavy",
  535. "rebalance_needed": True,
  536. "grid_ready": False,
  537. "base_ratio": 0.66,
  538. "quote_ratio": 0.34,
  539. }
  540. strategies = [
  541. {
  542. "id": "grid-1",
  543. "strategy_type": "grid_trader",
  544. "mode": "active",
  545. "account_id": "a1",
  546. "market_symbol": "xrpusd",
  547. "state": {"last_price": 111.0, "center_price": 100.0},
  548. "config": {"grid_step_pct": 0.05},
  549. },
  550. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
  551. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  552. ]
  553. history_window = {
  554. "window_seconds": 15 * 60,
  555. "recent_states": [
  556. {
  557. "created_at": "2026-04-18T20:06:00+00:00",
  558. "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"}}',
  559. },
  560. {
  561. "created_at": "2026-04-18T20:10:30+00:00",
  562. "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"}}',
  563. },
  564. ],
  565. }
  566. decision = make_decision(
  567. concern=concern,
  568. narrative_payload=narrative,
  569. wallet_state=wallet_state,
  570. strategies=strategies,
  571. history_window=history_window,
  572. )
  573. assert decision.action == "replace_with_trend_follower"
  574. assert decision.target_strategy == "trend-1"
  575. assert decision.payload["grid_breakout_pressure"]["time_window_memory"]["promoted_to_confirmed"] is True
  576. def test_normalize_strategy_snapshot_uses_live_report_contract_and_supervision():
  577. normalized = normalize_strategy_snapshot({
  578. "id": "grid-1",
  579. "strategy_type": "grid_trader",
  580. "mode": "active",
  581. "account_id": "a1",
  582. "report": {
  583. "fit": {
  584. "role": "primary",
  585. "inventory_behavior": "balanced",
  586. "safe_when_unbalanced": False,
  587. "can_run_with": ["exposure_protector"],
  588. },
  589. "state": {"last_action": "hold", "open_order_count": 12},
  590. "supervision": {"inventory_pressure": "base_heavy", "capacity_available": False, "side_capacity": {"buy": True, "sell": False}, "degraded": False},
  591. },
  592. })
  593. assert normalized["contract"]["inventory_behavior"] == "balanced"
  594. assert normalized["supervision"]["capacity_available"] is False
  595. assert normalized["open_order_count"] == 12
  596. def test_make_decision_keeps_trend_during_directional_regime_even_if_wallet_is_skewed():
  597. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  598. narrative = {
  599. "stance": "constructive_bullish",
  600. "confidence": 0.7,
  601. "opportunity_map": {"continuation": 0.8, "mean_reversion": 0.1, "reversal": 0.05, "wait": 0.05},
  602. }
  603. wallet_state = {
  604. "inventory_state": "critically_unbalanced",
  605. "rebalance_needed": True,
  606. "grid_ready": False,
  607. "base_ratio": 0.88,
  608. "quote_ratio": 0.12,
  609. }
  610. strategies = [
  611. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  612. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  613. ]
  614. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  615. assert decision.mode == "observe"
  616. assert decision.action == "keep_trend"
  617. assert decision.target_strategy == "trend-1"
  618. def test_make_decision_replaces_trend_with_rebalancer_after_trend_cools_and_wallet_needs_repair():
  619. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  620. narrative = {
  621. "stance": "neutral_rotational",
  622. "confidence": 0.65,
  623. "opportunity_map": {"continuation": 0.15, "mean_reversion": 0.25, "reversal": 0.2, "wait": 0.4},
  624. }
  625. wallet_state = {
  626. "inventory_state": "critically_unbalanced",
  627. "rebalance_needed": True,
  628. "grid_ready": False,
  629. "base_ratio": 0.88,
  630. "quote_ratio": 0.12,
  631. }
  632. strategies = [
  633. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  634. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  635. ]
  636. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  637. assert decision.mode == "warn"
  638. assert decision.action == "keep_trend"
  639. assert decision.target_strategy == "trend-1"
  640. def test_make_decision_replaces_trend_with_rebalancer_on_edge_cooling_even_before_full_rotational_stance():
  641. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  642. narrative = {
  643. "stance": "constructive_bullish",
  644. "confidence": 0.74,
  645. "opportunity_map": {"continuation": 0.58, "mean_reversion": 0.12, "reversal": 0.08, "wait": 0.22},
  646. "scoped_state": {
  647. "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "near_upper_band"},
  648. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  649. "macro": {"bias": "bullish"},
  650. },
  651. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  652. }
  653. wallet_state = {
  654. "inventory_state": "base_heavy",
  655. "rebalance_needed": True,
  656. "grid_ready": False,
  657. "base_ratio": 0.74,
  658. "quote_ratio": 0.26,
  659. }
  660. strategies = [
  661. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  662. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  663. ]
  664. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  665. assert decision.mode == "warn"
  666. assert decision.action == "keep_trend"
  667. assert decision.target_strategy == "trend-1"
  668. def test_make_decision_replaces_trend_with_rebalancer_when_micro_reversal_risk_spikes():
  669. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  670. narrative = {
  671. "stance": "constructive_bullish",
  672. "confidence": 0.76,
  673. "opportunity_map": {"continuation": 0.62, "mean_reversion": 0.1, "reversal": 0.18, "wait": 0.1},
  674. "scoped_state": {
  675. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "high"},
  676. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  677. "macro": {"bias": "bullish"},
  678. },
  679. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  680. }
  681. wallet_state = {
  682. "inventory_state": "base_heavy",
  683. "rebalance_needed": True,
  684. "grid_ready": False,
  685. "base_ratio": 0.72,
  686. "quote_ratio": 0.28,
  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 == "warn"
  694. assert decision.action == "keep_trend"
  695. assert decision.target_strategy == "trend-1"
  696. def test_make_decision_replaces_rebalancer_with_grid_when_balanced_and_rotational():
  697. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  698. narrative = {
  699. "stance": "neutral_rotational",
  700. "confidence": 0.68,
  701. "opportunity_map": {"continuation": 0.15, "mean_reversion": 0.72, "reversal": 0.05, "wait": 0.08},
  702. }
  703. wallet_state = {
  704. "inventory_state": "balanced",
  705. "rebalance_needed": False,
  706. "grid_ready": True,
  707. "base_ratio": 0.49,
  708. "quote_ratio": 0.51,
  709. }
  710. strategies = [
  711. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  712. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  713. ]
  714. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  715. assert decision.mode == "act"
  716. assert decision.action == "replace_with_grid"
  717. assert decision.target_strategy == "grid-1"
  718. def test_make_decision_replaces_rebalancer_with_grid_when_trend_is_directional_but_not_sustained():
  719. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  720. narrative = {
  721. "stance": "constructive_bullish",
  722. "confidence": 0.72,
  723. "opportunity_map": {"continuation": 0.4, "mean_reversion": 0.4, "reversal": 0.08, "wait": 0.12},
  724. "scoped_state": {
  725. "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "centered", "reversal_risk": "low"},
  726. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  727. "macro": {"bias": "bullish"},
  728. },
  729. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  730. }
  731. wallet_state = {
  732. "inventory_state": "balanced",
  733. "rebalance_needed": False,
  734. "grid_ready": True,
  735. "base_ratio": 0.51,
  736. "quote_ratio": 0.49,
  737. }
  738. strategies = [
  739. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  740. {"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}}},
  741. {"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}}},
  742. ]
  743. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  744. assert decision.mode == "act"
  745. assert decision.action == "replace_with_grid"
  746. assert decision.target_strategy == "grid-1"
  747. def test_make_decision_replaces_rebalancer_with_trend_when_breakout_is_still_strong():
  748. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  749. narrative = {
  750. "stance": "constructive_bullish",
  751. "confidence": 0.84,
  752. "opportunity_map": {"continuation": 0.82, "mean_reversion": 0.08, "reversal": 0.03, "wait": 0.07},
  753. "scoped_state": {
  754. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "low"},
  755. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  756. "macro": {"bias": "bullish"},
  757. },
  758. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  759. }
  760. wallet_state = {
  761. "inventory_state": "base_heavy",
  762. "rebalance_needed": True,
  763. "grid_ready": False,
  764. "base_ratio": 0.74,
  765. "quote_ratio": 0.26,
  766. }
  767. strategies = [
  768. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  769. {"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}}},
  770. {"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}}},
  771. ]
  772. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  773. assert decision.mode == "observe"
  774. assert decision.action == "keep_rebalancer"
  775. assert decision.target_strategy == "protect-1"