test_decision_engine.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619
  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": {"capacity_available": False, "side_capacity": {"buy": False, "sell": True}}}},
  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_keeps_grid_when_critically_unbalanced_but_grid_still_has_working_side_capacity():
  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.7,
  153. "opportunity_map": {"continuation": 0.55, "mean_reversion": 0.2, "reversal": 0.05, "wait": 0.2},
  154. "scoped_state": {
  155. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "upper_half", "reversal_risk": "low"},
  156. "meso": {"structure": "range", "momentum_bias": "bullish"},
  157. "macro": {"bias": "bullish"},
  158. },
  159. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  160. "features_by_timeframe": {"1m": {"raw": {"price": 1.4374}}},
  161. }
  162. wallet_state = {
  163. "inventory_state": "critically_unbalanced",
  164. "rebalance_needed": True,
  165. "grid_ready": False,
  166. "base_ratio": 0.86,
  167. "quote_ratio": 0.14,
  168. }
  169. strategies = [
  170. {
  171. "id": "grid-1",
  172. "strategy_type": "grid_trader",
  173. "mode": "active",
  174. "account_id": "a1",
  175. "state": {
  176. "last_price": 1.4374,
  177. "open_order_count": 4,
  178. "orders": [
  179. {"side": "sell", "status": "open", "price": "1.43956", "amount": "7"},
  180. {"side": "sell", "status": "open", "price": "1.44500", "amount": "7"},
  181. ],
  182. },
  183. "config": {},
  184. "report": {"supervision": {"capacity_available": False, "side_capacity": {"buy": False, "sell": True}, "inventory_pressure": "critical", "degraded": False}},
  185. },
  186. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  187. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  188. ]
  189. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  190. assert decision.mode == "observe"
  191. assert decision.action == "keep_grid"
  192. assert decision.target_strategy == "grid-1"
  193. def test_make_decision_keeps_grid_when_trend_has_only_eaten_two_levels():
  194. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  195. narrative = {
  196. "stance": "constructive_bullish",
  197. "confidence": 0.78,
  198. "opportunity_map": {"continuation": 0.72, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.15},
  199. "scoped_state": {
  200. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "upper_half", "reversal_risk": "low"},
  201. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  202. "macro": {"bias": "bullish"},
  203. },
  204. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  205. "features_by_timeframe": {"1m": {"raw": {"price": 110.0}}},
  206. }
  207. wallet_state = {
  208. "inventory_state": "balanced",
  209. "rebalance_needed": False,
  210. "grid_ready": True,
  211. "base_ratio": 0.52,
  212. "quote_ratio": 0.48,
  213. }
  214. strategies = [
  215. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 100.0}, "config": {"grid_step_pct": 0.05}},
  216. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  217. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  218. ]
  219. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  220. assert decision.mode == "warn"
  221. assert decision.action == "keep_grid"
  222. assert decision.target_strategy == "grid-1"
  223. def test_make_decision_replaces_grid_when_third_level_is_sustained():
  224. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  225. narrative = {
  226. "stance": "constructive_bullish",
  227. "confidence": 0.82,
  228. "opportunity_map": {"continuation": 0.82, "mean_reversion": 0.05, "reversal": 0.03, "wait": 0.1},
  229. "scoped_state": {
  230. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "low"},
  231. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  232. "macro": {"bias": "bullish"},
  233. },
  234. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  235. "features_by_timeframe": {"1m": {"raw": {"price": 116.0}}},
  236. }
  237. wallet_state = {
  238. "inventory_state": "balanced",
  239. "rebalance_needed": False,
  240. "grid_ready": True,
  241. "base_ratio": 0.52,
  242. "quote_ratio": 0.48,
  243. }
  244. strategies = [
  245. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {"center_price": 100.0}, "config": {"grid_step_pct": 0.05}},
  246. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  247. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  248. ]
  249. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  250. assert decision.mode == "act"
  251. assert decision.action == "replace_with_trend_follower"
  252. assert decision.target_strategy == "trend-1"
  253. def test_make_decision_replaces_grid_with_trend_when_breakout_is_persistent_but_inventory_is_only_base_heavy():
  254. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  255. narrative = {
  256. "stance": "constructive_bullish",
  257. "confidence": 0.78,
  258. "opportunity_map": {"continuation": 0.72, "mean_reversion": 0.08, "reversal": 0.05, "wait": 0.15},
  259. "scoped_state": {
  260. "micro": {"impulse": "up", "trend_bias": "bullish"},
  261. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  262. "macro": {"bias": "bullish"},
  263. },
  264. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  265. }
  266. wallet_state = {
  267. "inventory_state": "base_heavy",
  268. "rebalance_needed": True,
  269. "grid_ready": False,
  270. "base_ratio": 0.64,
  271. "quote_ratio": 0.36,
  272. }
  273. strategies = [
  274. {"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}}}},
  275. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  276. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  277. ]
  278. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  279. assert decision.action == "keep_grid"
  280. assert decision.target_strategy == "grid-1"
  281. def test_make_decision_prefers_active_grid_over_observe_trend_as_current_primary():
  282. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  283. narrative = {
  284. "stance": "constructive_bullish",
  285. "confidence": 0.72,
  286. "opportunity_map": {"continuation": 0.75, "mean_reversion": 0.1, "reversal": 0.05, "wait": 0.1},
  287. }
  288. wallet_state = {
  289. "inventory_state": "base_heavy",
  290. "rebalance_needed": True,
  291. "grid_ready": False,
  292. "base_ratio": 0.81,
  293. "quote_ratio": 0.19,
  294. }
  295. strategies = [
  296. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
  297. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  298. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  299. ]
  300. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  301. assert decision.action == "keep_grid"
  302. assert decision.target_strategy == "grid-1"
  303. def test_make_decision_prefers_trend_over_rebalancer_on_bullish_breakout_with_depleted_base():
  304. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  305. narrative = {
  306. "stance": "constructive_bullish",
  307. "confidence": 0.9,
  308. "opportunity_map": {"continuation": 0.85, "mean_reversion": 0.05, "reversal": 0.02, "wait": 0.08},
  309. "scoped_state": {
  310. "micro": {"impulse": "up", "trend_bias": "bullish"},
  311. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  312. "macro": {"bias": "bullish"},
  313. },
  314. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  315. }
  316. wallet_state = {
  317. "inventory_state": "critically_unbalanced",
  318. "rebalance_needed": True,
  319. "grid_ready": False,
  320. "base_ratio": 0.0,
  321. "quote_ratio": 1.0,
  322. }
  323. strategies = [
  324. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  325. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  326. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  327. ]
  328. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  329. assert decision.action == "replace_with_exposure_protector"
  330. assert decision.target_strategy == "protect-1"
  331. def test_make_decision_keeps_grid_when_next_sell_is_close_despite_persistent_breakout():
  332. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  333. narrative = {
  334. "stance": "constructive_bullish",
  335. "confidence": 0.9,
  336. "opportunity_map": {"continuation": 0.85, "mean_reversion": 0.05, "reversal": 0.02, "wait": 0.08},
  337. "features_by_timeframe": {
  338. "1m": {"raw": {"price": 1.4374, "atr_percent": 0.11}},
  339. },
  340. "scoped_state": {
  341. "micro": {"impulse": "up", "trend_bias": "bullish"},
  342. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  343. "macro": {"bias": "bullish"},
  344. },
  345. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  346. }
  347. wallet_state = {
  348. "inventory_state": "base_heavy",
  349. "rebalance_needed": True,
  350. "grid_ready": False,
  351. "base_ratio": 0.65,
  352. "quote_ratio": 0.35,
  353. }
  354. strategies = [
  355. {
  356. "id": "grid-1",
  357. "strategy_type": "grid_trader",
  358. "mode": "active",
  359. "account_id": "a1",
  360. "market_symbol": "xrpusd",
  361. "state": {
  362. "last_price": 1.4374,
  363. "orders": [
  364. {"side": "sell", "status": "open", "price": "1.43956", "amount": "7"},
  365. {"side": "buy", "status": "open", "price": "1.42523", "amount": "7"},
  366. ],
  367. },
  368. "config": {},
  369. },
  370. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "observe", "account_id": "a1", "state": {}, "config": {}},
  371. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  372. ]
  373. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  374. assert decision.action == "keep_grid"
  375. assert decision.target_strategy == "grid-1"
  376. def test_normalize_strategy_snapshot_uses_live_report_contract_and_supervision():
  377. normalized = normalize_strategy_snapshot({
  378. "id": "grid-1",
  379. "strategy_type": "grid_trader",
  380. "mode": "active",
  381. "account_id": "a1",
  382. "report": {
  383. "fit": {
  384. "role": "primary",
  385. "inventory_behavior": "balanced",
  386. "safe_when_unbalanced": False,
  387. "can_run_with": ["exposure_protector"],
  388. },
  389. "state": {"last_action": "hold", "open_order_count": 12},
  390. "supervision": {"inventory_pressure": "base_heavy", "capacity_available": False, "side_capacity": {"buy": True, "sell": False}, "degraded": False},
  391. },
  392. })
  393. assert normalized["contract"]["inventory_behavior"] == "balanced"
  394. assert normalized["supervision"]["capacity_available"] is False
  395. assert normalized["open_order_count"] == 12
  396. def test_make_decision_keeps_trend_during_directional_regime_even_if_wallet_is_skewed():
  397. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  398. narrative = {
  399. "stance": "constructive_bullish",
  400. "confidence": 0.7,
  401. "opportunity_map": {"continuation": 0.8, "mean_reversion": 0.1, "reversal": 0.05, "wait": 0.05},
  402. }
  403. wallet_state = {
  404. "inventory_state": "critically_unbalanced",
  405. "rebalance_needed": True,
  406. "grid_ready": False,
  407. "base_ratio": 0.88,
  408. "quote_ratio": 0.12,
  409. }
  410. strategies = [
  411. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  412. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  413. ]
  414. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  415. assert decision.mode == "observe"
  416. assert decision.action == "keep_trend"
  417. assert decision.target_strategy == "trend-1"
  418. def test_make_decision_replaces_trend_with_rebalancer_after_trend_cools_and_wallet_needs_repair():
  419. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  420. narrative = {
  421. "stance": "neutral_rotational",
  422. "confidence": 0.65,
  423. "opportunity_map": {"continuation": 0.15, "mean_reversion": 0.25, "reversal": 0.2, "wait": 0.4},
  424. }
  425. wallet_state = {
  426. "inventory_state": "critically_unbalanced",
  427. "rebalance_needed": True,
  428. "grid_ready": False,
  429. "base_ratio": 0.88,
  430. "quote_ratio": 0.12,
  431. }
  432. strategies = [
  433. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  434. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  435. ]
  436. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  437. assert decision.mode == "warn"
  438. assert decision.action == "keep_trend"
  439. assert decision.target_strategy == "trend-1"
  440. def test_make_decision_replaces_trend_with_rebalancer_on_edge_cooling_even_before_full_rotational_stance():
  441. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  442. narrative = {
  443. "stance": "constructive_bullish",
  444. "confidence": 0.74,
  445. "opportunity_map": {"continuation": 0.58, "mean_reversion": 0.12, "reversal": 0.08, "wait": 0.22},
  446. "scoped_state": {
  447. "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "near_upper_band"},
  448. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  449. "macro": {"bias": "bullish"},
  450. },
  451. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  452. }
  453. wallet_state = {
  454. "inventory_state": "base_heavy",
  455. "rebalance_needed": True,
  456. "grid_ready": False,
  457. "base_ratio": 0.74,
  458. "quote_ratio": 0.26,
  459. }
  460. strategies = [
  461. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  462. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  463. ]
  464. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  465. assert decision.mode == "warn"
  466. assert decision.action == "keep_trend"
  467. assert decision.target_strategy == "trend-1"
  468. def test_make_decision_replaces_trend_with_rebalancer_when_micro_reversal_risk_spikes():
  469. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  470. narrative = {
  471. "stance": "constructive_bullish",
  472. "confidence": 0.76,
  473. "opportunity_map": {"continuation": 0.62, "mean_reversion": 0.1, "reversal": 0.18, "wait": 0.1},
  474. "scoped_state": {
  475. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "high"},
  476. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  477. "macro": {"bias": "bullish"},
  478. },
  479. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  480. }
  481. wallet_state = {
  482. "inventory_state": "base_heavy",
  483. "rebalance_needed": True,
  484. "grid_ready": False,
  485. "base_ratio": 0.72,
  486. "quote_ratio": 0.28,
  487. }
  488. strategies = [
  489. {"id": "trend-1", "strategy_type": "trend_follower", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  490. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  491. ]
  492. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  493. assert decision.mode == "warn"
  494. assert decision.action == "keep_trend"
  495. assert decision.target_strategy == "trend-1"
  496. def test_make_decision_replaces_rebalancer_with_grid_when_balanced_and_rotational():
  497. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  498. narrative = {
  499. "stance": "neutral_rotational",
  500. "confidence": 0.68,
  501. "opportunity_map": {"continuation": 0.15, "mean_reversion": 0.72, "reversal": 0.05, "wait": 0.08},
  502. }
  503. wallet_state = {
  504. "inventory_state": "balanced",
  505. "rebalance_needed": False,
  506. "grid_ready": True,
  507. "base_ratio": 0.49,
  508. "quote_ratio": 0.51,
  509. }
  510. strategies = [
  511. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  512. {"id": "grid-1", "strategy_type": "grid_trader", "mode": "off", "account_id": "a1", "state": {}, "config": {}},
  513. ]
  514. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  515. assert decision.mode == "act"
  516. assert decision.action == "replace_with_grid"
  517. assert decision.target_strategy == "grid-1"
  518. def test_make_decision_replaces_rebalancer_with_grid_when_trend_is_directional_but_not_sustained():
  519. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  520. narrative = {
  521. "stance": "constructive_bullish",
  522. "confidence": 0.72,
  523. "opportunity_map": {"continuation": 0.4, "mean_reversion": 0.4, "reversal": 0.08, "wait": 0.12},
  524. "scoped_state": {
  525. "micro": {"impulse": "mixed", "trend_bias": "mixed", "location": "centered", "reversal_risk": "low"},
  526. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  527. "macro": {"bias": "bullish"},
  528. },
  529. "cross_scope_summary": {"alignment": "partial_alignment", "friction": "medium"},
  530. }
  531. wallet_state = {
  532. "inventory_state": "balanced",
  533. "rebalance_needed": False,
  534. "grid_ready": True,
  535. "base_ratio": 0.51,
  536. "quote_ratio": 0.49,
  537. }
  538. strategies = [
  539. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  540. {"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}}},
  541. {"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}}},
  542. ]
  543. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  544. assert decision.mode == "act"
  545. assert decision.action == "replace_with_grid"
  546. assert decision.target_strategy == "grid-1"
  547. def test_make_decision_replaces_rebalancer_with_trend_when_breakout_is_still_strong():
  548. concern = {"id": "c1", "account_id": "a1", "market_symbol": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"}
  549. narrative = {
  550. "stance": "constructive_bullish",
  551. "confidence": 0.84,
  552. "opportunity_map": {"continuation": 0.82, "mean_reversion": 0.08, "reversal": 0.03, "wait": 0.07},
  553. "scoped_state": {
  554. "micro": {"impulse": "up", "trend_bias": "bullish", "location": "near_upper_band", "reversal_risk": "low"},
  555. "meso": {"structure": "trend_continuation", "momentum_bias": "bullish"},
  556. "macro": {"bias": "bullish"},
  557. },
  558. "cross_scope_summary": {"alignment": "micro_meso_macro_aligned", "friction": "low"},
  559. }
  560. wallet_state = {
  561. "inventory_state": "base_heavy",
  562. "rebalance_needed": True,
  563. "grid_ready": False,
  564. "base_ratio": 0.74,
  565. "quote_ratio": 0.26,
  566. }
  567. strategies = [
  568. {"id": "protect-1", "strategy_type": "exposure_protector", "mode": "active", "account_id": "a1", "state": {}, "config": {}},
  569. {"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}}},
  570. {"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}}},
  571. ]
  572. decision = make_decision(concern=concern, narrative_payload=narrative, wallet_state=wallet_state, strategies=strategies)
  573. assert decision.mode == "observe"
  574. assert decision.action == "keep_rebalancer"
  575. assert decision.target_strategy == "protect-1"