test_decision_engine.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  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_assess_wallet_state_counts_reserved_orders_in_effective_inventory():
  42. concern = {"account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  43. account_info = {
  44. "balances": [
  45. {"asset_code": "XRP", "available": 0},
  46. {"asset_code": "USD", "available": 0},
  47. ]
  48. }
  49. strategies = [
  50. {
  51. "id": "grid-1",
  52. "strategy_type": "grid_trader",
  53. "mode": "active",
  54. "account_id": "a1",
  55. "market_symbol": "xrpusd",
  56. "state": {
  57. "orders": [
  58. {"side": "sell", "status": "open", "amount": "10", "price": "1.50"},
  59. {"side": "buy", "status": "open", "amount": "10", "price": "1.40"},
  60. ]
  61. },
  62. }
  63. ]
  64. wallet = assess_wallet_state(account_info=account_info, concern=concern, price=1.45, strategies=strategies)
  65. assert wallet["inventory_state"] == "balanced"
  66. assert wallet["base_reserved"] == 10.0
  67. assert wallet["quote_reserved"] == 14.0
  68. assert wallet["base_effective"] == 10.0
  69. assert wallet["quote_effective"] == 14.0
  70. def test_make_decision_keeps_grid_when_imbalance_is_manageable():
  71. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  72. narrative = {
  73. "stance": "constructive_bullish",
  74. "confidence": 0.72,
  75. "opportunity_map": {"continuation": 0.75, "mean_reversion": 0.1, "reversal": 0.05, "wait": 0.1},
  76. }
  77. wallet_state = {
  78. "inventory_state": "base_heavy",
  79. "rebalance_needed": True,
  80. "grid_ready": False,
  81. "base_ratio": 0.8,
  82. "quote_ratio": 0.2,
  83. }
  84. strategies = [
  85. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  86. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  87. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  88. ]
  89. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  90. assert decision.mode == "observe"
  91. assert decision.action == "keep_grid"
  92. assert decision.target_strategy == "grid-1"
  93. def test_make_decision_does_not_replace_grid_with_rebalancer_only_because_grid_mentions_handoff_readiness():
  94. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  95. narrative = {
  96. "stance": "cautious_bullish",
  97. "confidence": 0.74,
  98. "opportunity_map": {"continuation": 0.5, "mean_reversion": 0.25, "reversal": 0.05, "wait": 0.2},
  99. "scoped_state": {
  100. "micro": {"impulse": "mixed", "trend_bias": "mixed"},
  101. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  102. "macro": {"bias": "bullish"},
  103. },
  104. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  105. }
  106. wallet_state = {
  107. "inventory_state": "base_heavy",
  108. "rebalance_needed": True,
  109. "grid_ready": False,
  110. "base_ratio": 0.64,
  111. "quote_ratio": 0.36,
  112. }
  113. strategies = [
  114. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}, "report": {"supervision": {"switch_readiness": "ready_for_handoff"}}},
  115. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  116. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  117. ]
  118. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  119. assert decision.action == "keep_grid"
  120. assert decision.target_strategy == "grid-1"
  121. def test_make_decision_replaces_grid_when_breakout_pressure_is_persistent():
  122. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  123. narrative = {
  124. "stance": "constructive_bullish",
  125. "confidence": 0.78,
  126. "opportunity_map": {"continuation": 0.72, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.15},
  127. "scoped_state": {
  128. "micro": {"impulse": "up", "trend_bias": "bullish"},
  129. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  130. "macro": {"bias": "bullish"},
  131. },
  132. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  133. }
  134. wallet_state = {
  135. "inventory_state": "critically_unbalanced",
  136. "rebalance_needed": True,
  137. "grid_ready": False,
  138. "base_ratio": 0.88,
  139. "quote_ratio": 0.12,
  140. }
  141. strategies = [
  142. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  143. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  144. ]
  145. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  146. assert decision.action == "replace_with_exposure_protector"
  147. assert decision.target_strategy == "protect-1"
  148. def test_make_decision_replaces_grid_with_trend_when_breakout_is_persistent_but_inventory_is_only_base_heavy():
  149. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  150. narrative = {
  151. "stance": "constructive_bullish",
  152. "confidence": 0.78,
  153. "opportunity_map": {"continuation": 0.72, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.15},
  154. "scoped_state": {
  155. "micro": {"impulse": "up", "trend_bias": "bullish"},
  156. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  157. "macro": {"bias": "bullish"},
  158. },
  159. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  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": {"switch_readiness": "watch_handoff"}}},
  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 == "replace_with_trend_follower"
  175. assert decision.target_strategy == "trend-1"
  176. def test_make_decision_prefers_active_grid_over_observe_trend_as_current_primary():
  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.72,
  181. "opportunity_map": {"continuation": 0.75, "mean_reversion": 0.1, "reversal": 0.05, "wait": 0.1},
  182. }
  183. wallet_state = {
  184. "inventory_state": "base_heavy",
  185. "rebalance_needed": True,
  186. "grid_ready": False,
  187. "base_ratio": 0.81,
  188. "quote_ratio": 0.19,
  189. }
  190. strategies = [
  191. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
  192. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  193. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  194. ]
  195. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  196. assert decision.action == "keep_grid"
  197. assert decision.target_strategy == "grid-1"
  198. def test_make_decision_prefers_trend_over_rebalancer_on_bullish_breakout_with_depleted_base():
  199. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  200. narrative = {
  201. "stance": "constructive_bullish",
  202. "confidence": 0.9,
  203. "opportunity_map": {"continuation": 0.85, "mean_reversion": 0.05, "reversal": 0.02, "wait": 0.08},
  204. "scoped_state": {
  205. "micro": {"impulse": "up", "trend_bias": "bullish"},
  206. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  207. "macro": {"bias": "bullish"},
  208. },
  209. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  210. }
  211. wallet_state = {
  212. "inventory_state": "critically_unbalanced",
  213. "rebalance_needed": True,
  214. "grid_ready": False,
  215. "base_ratio": 0.0,
  216. "quote_ratio": 1.0,
  217. }
  218. strategies = [
  219. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  220. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  221. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  222. ]
  223. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  224. assert decision.action == "replace_with_trend_follower"
  225. assert decision.target_strategy == "trend-1"
  226. def test_make_decision_keeps_grid_when_next_sell_is_close_despite_persistent_breakout():
  227. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  228. narrative = {
  229. "stance": "constructive_bullish",
  230. "confidence": 0.9,
  231. "opportunity_map": {"continuation": 0.85, "mean_reversion": 0.05, "reversal": 0.02, "wait": 0.08},
  232. "features_by_timeframe": {
  233. "1m": {"raw": {"price": 1.4374, "atr_percent": 0.11}},
  234. },
  235. "scoped_state": {
  236. "micro": {"impulse": "up", "trend_bias": "bullish"},
  237. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  238. "macro": {"bias": "bullish"},
  239. },
  240. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  241. }
  242. wallet_state = {
  243. "inventory_state": "base_heavy",
  244. "rebalance_needed": True,
  245. "grid_ready": False,
  246. "base_ratio": 0.65,
  247. "quote_ratio": 0.35,
  248. }
  249. strategies = [
  250. {
  251. "id": "grid-1",
  252. "strategy_type": "grid_trader",
  253. "mode": "active",
  254. "account_id": "a1",
  255. "market_symbol": "xrpusd",
  256. "state": {
  257. "last_price": 1.4374,
  258. "orders": [
  259. {"side": "sell", "status": "open", "price": "1.43956", "amount": "7"},
  260. {"side": "buy", "status": "open", "price": "1.42523", "amount": "7"},
  261. ],
  262. },
  263. "config": {},
  264. },
  265. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
  266. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  267. ]
  268. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  269. assert decision.action == "keep_grid"
  270. assert decision.target_strategy == "grid-1"
  271. def test_normalize_strategy_snapshot_uses_live_report_contract_and_supervision():
  272. normalized = normalize_strategy_snapshot({
  273. "id": "grid-1",
  274. "strategy_type": "grid_trader",
  275. "mode": "active",
  276. "account_id": "a1",
  277. "report": {
  278. "fit": {
  279. "role": "primary",
  280. "inventory_behavior": "balanced",
  281. "safe_when_unbalanced": False,
  282. "can_run_with": ["exposure_protector"],
  283. },
  284. "state": {"last_action": "hold", "open_order_count": 12},
  285. "supervision": {"inventory_pressure": "base_heavy", "switch_readiness": "ready_for_handoff", "degraded": False},
  286. },
  287. })
  288. assert normalized["contract"]["inventory_behavior"] == "balanced"
  289. assert normalized["supervision"]["switch_readiness"] == "ready_for_handoff"
  290. assert normalized["open_order_count"] == 12
  291. def test_make_decision_keeps_trend_during_directional_regime_even_if_wallet_is_skewed():
  292. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  293. narrative = {
  294. "stance": "constructive_bullish",
  295. "confidence": 0.7,
  296. "opportunity_map": {"continuation": 0.8, "mean_reversion": 0.1, "reversal": 0.05, "wait": 0.05},
  297. }
  298. wallet_state = {
  299. "inventory_state": "critically_unbalanced",
  300. "rebalance_needed": True,
  301. "grid_ready": False,
  302. "base_ratio": 0.88,
  303. "quote_ratio": 0.12,
  304. }
  305. strategies = [
  306. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  307. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  308. ]
  309. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  310. assert decision.mode == "observe"
  311. assert decision.action == "keep_trend"
  312. assert decision.target_strategy == "trend-1"
  313. def test_make_decision_replaces_trend_with_rebalancer_after_trend_cools_and_wallet_needs_repair():
  314. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  315. narrative = {
  316. "stance": "neutral_rotational",
  317. "confidence": 0.65,
  318. "opportunity_map": {"continuation": 0.15, "mean_reversion": 0.25, "reversal": 0.2, "wait": 0.4},
  319. }
  320. wallet_state = {
  321. "inventory_state": "critically_unbalanced",
  322. "rebalance_needed": True,
  323. "grid_ready": False,
  324. "base_ratio": 0.88,
  325. "quote_ratio": 0.12,
  326. }
  327. strategies = [
  328. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  329. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  330. ]
  331. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  332. assert decision.mode == "act"
  333. assert decision.action == "replace_with_exposure_protector"
  334. assert decision.target_strategy == "protect-1"
  335. def test_make_decision_replaces_trend_with_rebalancer_on_edge_cooling_even_before_full_rotational_stance():
  336. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  337. narrative = {
  338. "stance": "constructive_bullish",
  339. "confidence": 0.74,
  340. "opportunity_map": {"continuation": 0.58, "mean_reversion": 0.12, "reversal": 0.08, "wait": 0.22},
  341. "scoped_state": {
  342. "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "near_upper_band"},
  343. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  344. "macro": {"bias": "bullish"},
  345. },
  346. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  347. }
  348. wallet_state = {
  349. "inventory_state": "base_heavy",
  350. "rebalance_needed": True,
  351. "grid_ready": False,
  352. "base_ratio": 0.74,
  353. "quote_ratio": 0.26,
  354. }
  355. strategies = [
  356. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  357. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  358. ]
  359. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  360. assert decision.mode == "act"
  361. assert decision.action == "replace_with_exposure_protector"
  362. assert decision.target_strategy == "protect-1"
  363. def test_make_decision_replaces_rebalancer_with_grid_when_balanced_and_rotational():
  364. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  365. narrative = {
  366. "stance": "neutral_rotational",
  367. "confidence": 0.68,
  368. "opportunity_map": {"continuation": 0.15, "mean_reversion": 0.72, "reversal": 0.05, "wait": 0.08},
  369. }
  370. wallet_state = {
  371. "inventory_state": "balanced",
  372. "rebalance_needed": False,
  373. "grid_ready": True,
  374. "base_ratio": 0.49,
  375. "quote_ratio": 0.51,
  376. }
  377. strategies = [
  378. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  379. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  380. ]
  381. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  382. assert decision.mode == "act"
  383. assert decision.action == "replace_with_grid"
  384. assert decision.target_strategy == "grid-1"