test_decision_engine.py 61 KB

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