test_strategies.py 59 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715
  1. from __future__ import annotations
  2. from pathlib import Path
  3. from tempfile import TemporaryDirectory
  4. from fastapi.testclient import TestClient
  5. from src.trader_mcp import strategy_registry, strategy_store
  6. from src.trader_mcp.server import app
  7. from src.trader_mcp.strategy_context import StrategyContext
  8. from src.trader_mcp.strategy_sizing import cap_amount_to_balance_target
  9. from src.trader_mcp.strategy_sdk import Strategy as BaseStrategy
  10. from strategies.exposure_protector import Strategy as ExposureStrategy
  11. from strategies.dumb_trader import Strategy as DumbStrategy
  12. from strategies.grid_trader import Strategy as GridStrategy
  13. STRATEGY_CODE = '''
  14. from src.trader_mcp.strategy_sdk import Strategy
  15. class Strategy(Strategy):
  16. def init(self):
  17. return {"started": True, "config_copy": dict(self.config)}
  18. '''
  19. def test_strategies_endpoints_roundtrip():
  20. with TemporaryDirectory() as tmpdir:
  21. strategy_store.DB_PATH = Path(tmpdir) / "trader_mcp.sqlite3"
  22. from src.trader_mcp import strategy_registry
  23. strategy_registry.STRATEGIES_DIR = Path(tmpdir) / "strategies"
  24. strategy_registry.STRATEGIES_DIR.mkdir()
  25. (strategy_registry.STRATEGIES_DIR / "demo.py").write_text(STRATEGY_CODE)
  26. client = TestClient(app)
  27. r = client.get("/strategies")
  28. assert r.status_code == 200
  29. body = r.json()
  30. assert "available" in body
  31. assert "configured" in body
  32. r = client.post(
  33. "/strategies",
  34. json={
  35. "id": "demo-1",
  36. "strategy_type": "demo",
  37. "account_id": "acct-1",
  38. "client_id": "strategy:test",
  39. "mode": "observe",
  40. "config": {"risk": 0.01},
  41. },
  42. )
  43. assert r.status_code == 200
  44. assert r.json()["id"] == "demo-1"
  45. r = client.get("/strategies")
  46. assert any(item["id"] == "demo-1" for item in r.json()["configured"])
  47. r = client.delete("/strategies/demo-1")
  48. assert r.status_code == 200
  49. assert r.json()["ok"] is True
  50. def test_strategy_context_binds_identity(monkeypatch):
  51. calls = {}
  52. def fake_place_order(arguments):
  53. calls["place_order"] = arguments
  54. return {"ok": True}
  55. def fake_open_orders(account_id, client_id=None):
  56. calls["open_orders"] = {"account_id": account_id, "client_id": client_id}
  57. return {"ok": True}
  58. def fake_cancel_all(account_id, client_id=None):
  59. calls["cancel_all"] = {"account_id": account_id, "client_id": client_id}
  60. return {"ok": True}
  61. monkeypatch.setattr("src.trader_mcp.strategy_context.place_order", fake_place_order)
  62. monkeypatch.setattr("src.trader_mcp.strategy_context.list_open_orders", fake_open_orders)
  63. monkeypatch.setattr("src.trader_mcp.strategy_context.cancel_all_orders", fake_cancel_all)
  64. ctx = StrategyContext(id="inst-1", account_id="acct-1", client_id="client-1", mode="active")
  65. ctx.place_order(side="sell", market="xrpusd", order_type="limit", amount="10", price="2")
  66. ctx.get_open_orders()
  67. ctx.cancel_all_orders()
  68. assert calls["place_order"]["account_id"] == "acct-1"
  69. assert calls["place_order"]["client_id"] == "client-1"
  70. assert calls["open_orders"] == {"account_id": "acct-1", "client_id": "client-1"}
  71. assert calls["cancel_all"] == {"account_id": "acct-1", "client_id": "client-1"}
  72. def test_strategy_context_cancel_all_orders_confirmed_after_empty_followup(monkeypatch):
  73. calls = {"open_orders": 0}
  74. def fake_cancel_all(account_id, client_id=None):
  75. calls["cancel_all"] = {"account_id": account_id, "client_id": client_id}
  76. return {"ok": True, "cancelled": [{"ok": True, "order_id": "o1"}]}
  77. def fake_open_orders(account_id, client_id=None):
  78. calls["open_orders"] += 1
  79. return {"orders": []}
  80. monkeypatch.setattr("src.trader_mcp.strategy_context.cancel_all_orders", fake_cancel_all)
  81. monkeypatch.setattr("src.trader_mcp.strategy_context.list_open_orders", fake_open_orders)
  82. ctx = StrategyContext(id="inst-1", account_id="acct-1", client_id="client-1", mode="active")
  83. result = ctx.cancel_all_orders_confirmed()
  84. assert result["conclusive"] is True
  85. assert result["cleanup_status"] == "cleanup_confirmed"
  86. assert result["cancelled_order_ids"] == ["o1"]
  87. assert result["remaining_orders"] == []
  88. assert result["error"] is None
  89. assert calls["cancel_all"] == {"account_id": "acct-1", "client_id": "client-1"}
  90. assert calls["open_orders"] == 1
  91. def test_strategy_context_cancel_all_orders_confirmed_preserves_inconclusive_failure(monkeypatch):
  92. calls = {"open_orders": 0}
  93. def fake_cancel_all(account_id, client_id=None):
  94. raise RuntimeError("auth breaker active")
  95. def fake_open_orders(account_id, client_id=None):
  96. calls["open_orders"] += 1
  97. return {"orders": [{"id": "o1"}]}
  98. monkeypatch.setattr("src.trader_mcp.strategy_context.cancel_all_orders", fake_cancel_all)
  99. monkeypatch.setattr("src.trader_mcp.strategy_context.list_open_orders", fake_open_orders)
  100. ctx = StrategyContext(id="inst-1", account_id="acct-1", client_id="client-1", mode="active")
  101. result = ctx.cancel_all_orders_confirmed()
  102. assert result["conclusive"] is False
  103. assert result["cleanup_status"] == "cleanup_failed"
  104. assert result["cancelled_order_ids"] == []
  105. assert result["remaining_orders"] == [{"id": "o1"}]
  106. assert result["error"] == "auth breaker active"
  107. assert calls["open_orders"] == 1
  108. def test_strategy_context_cancel_all_orders_confirmed_keeps_partial_reply_inconclusive(monkeypatch):
  109. calls = {"open_orders": 0}
  110. def fake_cancel_all(account_id, client_id=None):
  111. calls["cancel_all"] = {"account_id": account_id, "client_id": client_id}
  112. return {
  113. "ok": True,
  114. "cancelled": [
  115. {"ok": True, "order_id": "o1"},
  116. {"ok": False, "order_id": "o2", "status": "deferred", "error": "auth breaker active"},
  117. ],
  118. }
  119. def fake_open_orders(account_id, client_id=None):
  120. calls["open_orders"] += 1
  121. return {"orders": []}
  122. monkeypatch.setattr("src.trader_mcp.strategy_context.cancel_all_orders", fake_cancel_all)
  123. monkeypatch.setattr("src.trader_mcp.strategy_context.list_open_orders", fake_open_orders)
  124. ctx = StrategyContext(id="inst-1", account_id="acct-1", client_id="client-1", mode="active")
  125. result = ctx.cancel_all_orders_confirmed()
  126. assert result["conclusive"] is False
  127. assert result["cleanup_status"] == "cleanup_partial"
  128. assert result["cancelled_order_ids"] == ["o1"]
  129. assert result["remaining_orders"] == []
  130. assert result["error"] == "cancel-all reported uncancelled orders"
  131. assert calls["cancel_all"] == {"account_id": "acct-1", "client_id": "client-1"}
  132. assert calls["open_orders"] == 1
  133. def test_stop_loss_strategy_loads_with_aligned_regime_config(tmp_path):
  134. original_db = strategy_store.DB_PATH
  135. original_dir = strategy_registry.STRATEGIES_DIR
  136. try:
  137. strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
  138. strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
  139. strategy_registry.STRATEGIES_DIR.mkdir()
  140. (strategy_registry.STRATEGIES_DIR / "grid_trader.py").write_text((Path(__file__).resolve().parents[1] / "strategies" / "grid_trader.py").read_text())
  141. (strategy_registry.STRATEGIES_DIR / "exposure_protector.py").write_text((Path(__file__).resolve().parents[1] / "strategies" / "exposure_protector.py").read_text())
  142. grid_defaults = strategy_registry.get_strategy_default_config("grid_trader")
  143. stop_defaults = strategy_registry.get_strategy_default_config("exposure_protector")
  144. assert grid_defaults["trade_sides"] == "both"
  145. assert grid_defaults["grid_step_pct"] == 0.012
  146. assert stop_defaults["trail_distance_pct"] == 0.03
  147. assert stop_defaults["rebalance_target_ratio"] == 0.5
  148. assert stop_defaults["min_rebalance_seconds"] == 180
  149. assert stop_defaults["min_price_move_pct"] == 0.005
  150. finally:
  151. strategy_store.DB_PATH = original_db
  152. strategy_registry.STRATEGIES_DIR = original_dir
  153. def test_grid_supervision_reports_factual_capacity_not_handoff_commands():
  154. class FakeContext:
  155. account_id = "acct-1"
  156. market_symbol = "xrpusd"
  157. base_currency = "XRP"
  158. counter_currency = "USD"
  159. mode = "active"
  160. strategy = GridStrategy(FakeContext(), {})
  161. strategy.state.update({
  162. "last_price": 1.45,
  163. "base_available": 50.0,
  164. "counter_available": 38.7,
  165. "regimes": {"1h": {"trend": {"state": "bull"}}},
  166. })
  167. supervision = strategy._supervision()
  168. assert supervision["inventory_pressure"] == "base_heavy"
  169. assert supervision["capacity_available"] is False
  170. assert supervision["side_capacity"] == {"buy": True, "sell": True}
  171. strategy.state.update({
  172. "base_available": 88.0,
  173. "counter_available": 4.0,
  174. })
  175. supervision = strategy._supervision()
  176. assert supervision["inventory_pressure"] == "base_side_depleted"
  177. assert supervision["side_capacity"] == {"buy": True, "sell": False}
  178. def test_grid_supervision_exposes_adverse_side_open_orders():
  179. class FakeContext:
  180. account_id = "acct-1"
  181. market_symbol = "xrpusd"
  182. base_currency = "XRP"
  183. counter_currency = "USD"
  184. mode = "active"
  185. strategy = GridStrategy(FakeContext(), {})
  186. strategy.state.update({
  187. "last_price": 1.60,
  188. "center_price": 1.45,
  189. "orders": [
  190. {"side": "sell", "price": "1.62", "amount": "10", "status": "open"},
  191. {"side": "sell", "price": "1.66", "amount": "5", "status": "open"},
  192. {"side": "buy", "price": "1.38", "amount": "7", "status": "open"},
  193. ],
  194. })
  195. supervision = strategy._supervision()
  196. assert supervision["market_bias"] == "bullish"
  197. assert supervision["adverse_side"] == "sell"
  198. assert supervision["adverse_side_open_order_count"] == 2
  199. assert supervision["adverse_side_open_order_notional_quote"] > 0
  200. assert "sell ladder exposed" in " ".join(supervision["concerns"])
  201. def test_grid_rebalance_skew_uses_total_wallet_not_free_balance():
  202. class FakeContext:
  203. account_id = "acct-1"
  204. market_symbol = "xrpusd"
  205. base_currency = "XRP"
  206. counter_currency = "USD"
  207. mode = "active"
  208. strategy = GridStrategy(FakeContext(), {"grid_step_pct": 0.01, "grid_step_min_pct": 0.001, "inventory_rebalance_step_factor": 0.15})
  209. strategy.state.update(
  210. {
  211. "base_available": 1.0,
  212. "counter_available": 100.0,
  213. "orders": [
  214. {"side": "sell", "price": 10.0, "amount": 10.0, "id": "locked-sell"},
  215. ],
  216. }
  217. )
  218. steps = strategy._effective_grid_steps(10.0)
  219. assert steps["favored_side"] == "sell"
  220. assert steps["sell"] < steps["buy"]
  221. def test_grid_on_tick_honors_refresh_pause_before_shape_rebuild(monkeypatch):
  222. class FakeContext:
  223. account_id = "acct-1"
  224. market_symbol = "xrpusd"
  225. base_currency = "XRP"
  226. counter_currency = "USD"
  227. mode = "active"
  228. minimum_order_value = 1.0
  229. def get_account_info(self):
  230. return {
  231. "balances": [
  232. {"asset_code": "XRP", "available": 100.0, "total": 100.0},
  233. {"asset_code": "USD", "available": 100.0, "total": 100.0},
  234. ]
  235. }
  236. def get_price(self, _asset):
  237. return {"price": 1.0}
  238. def get_regime(self, *_args, **_kwargs):
  239. return {"volatility": {"atr_percent": 0.0}, "trend": {"state": "flat"}}
  240. def get_open_orders(self):
  241. return [{"bitstamp_order_id": "live-1", "side": "buy", "price": 0.99, "amount": 1.0}]
  242. strategy = GridStrategy(FakeContext(), {})
  243. strategy.state.update(
  244. {
  245. "center_price": 1.0,
  246. "seeded": True,
  247. "orders": [{"bitstamp_order_id": "old-1", "side": "buy", "price": 0.99, "amount": 1.0}],
  248. "order_ids": ["old-1"],
  249. "grid_refresh_pending_until": 9999999999.0,
  250. }
  251. )
  252. called = {"rebuild": 0}
  253. def fake_rebuild(*_args, **_kwargs):
  254. called["rebuild"] += 1
  255. monkeypatch.setattr(strategy, "_recenter_and_rebuild_from_price", fake_rebuild)
  256. result = strategy.on_tick({"ts": 0, "strategy_id": "grid-1"})
  257. assert result["action"] == "hold"
  258. assert result["refresh_paused"] is True
  259. assert called["rebuild"] == 0
  260. def test_dumb_trader_and_protector_supervision_reports_facts_only():
  261. class FakeContext:
  262. account_id = "acct-1"
  263. market_symbol = "xrpusd"
  264. base_currency = "XRP"
  265. counter_currency = "USD"
  266. mode = "active"
  267. trend = DumbStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 1.0})
  268. trend.state.update({"last_price": 1.45, "base_available": 20.0, "counter_available": 20.0, "last_order_at": 0.0})
  269. trend_supervision = trend._supervision()
  270. assert trend_supervision["trade_side"] == "buy"
  271. assert trend_supervision["capacity_available"] is True
  272. assert trend_supervision["entry_offset_pct"] == 0.003
  273. assert trend_supervision["chasing_risk"] in {"low", "moderate", "elevated"}
  274. assert "switch_readiness" not in trend_supervision
  275. assert "desired_companion" not in trend_supervision
  276. protector = ExposureStrategy(FakeContext(), {})
  277. protector.state.update({"last_price": 1.45, "base_available": 40.0, "counter_available": 10.0})
  278. protector_supervision = protector._supervision()
  279. assert protector_supervision["rebalance_needed"] is True
  280. assert protector_supervision["repair_progress"] <= 1.0
  281. assert "switch_readiness" not in protector_supervision
  282. assert "desired_companion" not in protector_supervision
  283. def test_exposure_protector_holds_inside_hysteresis_band(monkeypatch):
  284. class FakeContext:
  285. account_id = "acct-1"
  286. market_symbol = "xrpusd"
  287. base_currency = "XRP"
  288. counter_currency = "USD"
  289. mode = "active"
  290. def __init__(self):
  291. self.placed_orders = []
  292. def get_account_info(self):
  293. return {"balances": [{"asset_code": "XRP", "available": 9.2}, {"asset_code": "USD", "available": 10.0}]}
  294. def get_price(self, market):
  295. return {"price": 1.0}
  296. def get_fee_rates(self, market):
  297. return {"maker": 0.0, "taker": 0.004}
  298. def place_order(self, **kwargs):
  299. self.placed_orders.append(kwargs)
  300. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  301. ctx = FakeContext()
  302. strategy = ExposureStrategy(ctx, {"rebalance_target_ratio": 0.5, "rebalance_step_ratio": 0.15, "balance_tolerance": 0.05, "cooldown_ticks": 0, "min_rebalance_seconds": 0, "trail_distance_pct": 0.03})
  303. strategy.state["last_rebalance_side"] = "sell"
  304. strategy.state["last_order_at"] = 0
  305. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  306. result = strategy.on_tick({})
  307. assert result["action"] == "hold"
  308. assert result["reason"] == "within rebalance hysteresis"
  309. assert ctx.placed_orders == []
  310. def test_grid_apply_policy_keeps_explicit_grid_levels():
  311. class FakeContext:
  312. account_id = "acct-1"
  313. market_symbol = "xrpusd"
  314. base_currency = "XRP"
  315. counter_currency = "USD"
  316. mode = "active"
  317. strategy = GridStrategy(FakeContext(), {"grid_levels": 5, "policy": {"risk_posture": "normal"}})
  318. strategy.apply_policy()
  319. assert strategy.config["grid_levels"] == 5
  320. assert strategy.state["policy_derived"]["grid_levels"] == 5
  321. def test_grid_seed_keeps_other_side_when_one_side_fails(monkeypatch):
  322. class FakeContext:
  323. base_currency = "XRP"
  324. counter_currency = "USD"
  325. market_symbol = "xrpusd"
  326. minimum_order_value = 10.0
  327. mode = "active"
  328. def __init__(self):
  329. self.attempts = []
  330. self.buy_attempts = 0
  331. self.sell_attempts = 0
  332. def get_fee_rates(self, market):
  333. return {"maker": 0.0, "taker": 0.0}
  334. def suggest_order_amount(self, **kwargs):
  335. return 1.0
  336. def place_order(self, **kwargs):
  337. self.attempts.append(kwargs)
  338. if kwargs["side"] == "buy":
  339. self.buy_attempts += 1
  340. if self.buy_attempts == 3:
  341. raise RuntimeError("insufficient USD")
  342. elif kwargs["side"] == "sell":
  343. self.sell_attempts += 1
  344. return {"status": "ok", "id": f"{kwargs['side']}-{len(self.attempts)}"}
  345. ctx = FakeContext()
  346. strategy = GridStrategy(ctx, {"grid_levels": 5, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.0})
  347. strategy.state["center_price"] = 100.0
  348. strategy.state["base_available"] = 1000.0
  349. strategy.state["counter_available"] = 1000.0
  350. monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: None)
  351. strategy._place_grid(100.0)
  352. orders = strategy.state["orders"]
  353. assert ctx.buy_attempts == 5
  354. assert ctx.sell_attempts == 5
  355. assert len([o for o in orders if o["side"] == "buy"]) == 4
  356. assert len([o for o in orders if o["side"] == "sell"]) == 5
  357. assert any("partial success" in line for line in (strategy.state.get("debug_log") or [])) or strategy.state.get("last_error") == "insufficient USD"
  358. def test_grid_plan_extends_sell_ladder_past_skipped_inner_level(monkeypatch):
  359. class FakeContext:
  360. base_currency = "XRP"
  361. counter_currency = "USD"
  362. market_symbol = "xrpusd"
  363. minimum_order_value = 10.0
  364. mode = "active"
  365. def get_fee_rates(self, market):
  366. return {"maker": 0.0, "taker": 0.0}
  367. def suggest_order_amount(self, **kwargs):
  368. return 7.29474
  369. strategy = GridStrategy(FakeContext(), {"grid_levels": 5, "order_call_delay_ms": 0})
  370. monkeypatch.setattr(
  371. strategy,
  372. "_effective_grid_steps",
  373. lambda center, **kwargs: {"base": 0.00718865, "buy": 0.00718865, "sell": 0.006407884093550591},
  374. )
  375. plan = strategy._plan_grid(1.3615, base_total=49.035, quote_total=19.77)
  376. assert plan["counts"]["buy"] == 0
  377. assert plan["counts"]["sell"] == 5
  378. assert [order["level"] for order in plan["sell_orders"]] == [2, 3, 4, 5, 6]
  379. assert (plan["sell_skipped"] or [])[0]["level"] == 1
  380. def test_grid_shape_check_reuses_canonical_plan_without_rebuild(monkeypatch):
  381. class FakeContext:
  382. base_currency = "XRP"
  383. counter_currency = "USD"
  384. market_symbol = "xrpusd"
  385. minimum_order_value = 10.0
  386. mode = "active"
  387. def __init__(self):
  388. self.cancelled_all = 0
  389. def get_fee_rates(self, market):
  390. return {"maker": 0.0, "taker": 0.0}
  391. def get_account_info(self):
  392. return {
  393. "balances": [
  394. {"asset_code": "USD", "available": 19.77},
  395. {"asset_code": "XRP", "available": 12.5613},
  396. ]
  397. }
  398. def get_price(self, symbol):
  399. return {"price": 1.3615}
  400. def get_regime(self, symbol, timeframe="1h"):
  401. return {"volatility": {"atr_percent": 0.0}, "trend": {"state": "flat"}}
  402. def suggest_order_amount(self, **kwargs):
  403. return 7.29474
  404. def get_open_orders(self):
  405. return [
  406. {"side": "sell", "price": 1.37894867, "amount": 7.29474, "id": "s2"},
  407. {"side": "sell", "price": 1.387673, "amount": 7.29474, "id": "s3"},
  408. {"side": "sell", "price": 1.39639734, "amount": 7.29474, "id": "s4"},
  409. {"side": "sell", "price": 1.40512167, "amount": 7.29474, "id": "s5"},
  410. {"side": "sell", "price": 1.41384601, "amount": 7.29474, "id": "s6"},
  411. ]
  412. def cancel_all_orders(self):
  413. self.cancelled_all += 1
  414. return {"ok": True}
  415. ctx = FakeContext()
  416. strategy = GridStrategy(ctx, {"grid_levels": 5, "order_call_delay_ms": 0})
  417. strategy.state["center_price"] = 1.3615
  418. strategy.state["seeded"] = True
  419. strategy.state["orders"] = ctx.get_open_orders()
  420. strategy.state["order_ids"] = ["s2", "s3", "s4", "s5", "s6"]
  421. monkeypatch.setattr(
  422. strategy,
  423. "_effective_grid_steps",
  424. lambda center, **kwargs: {"base": 0.00718865, "buy": 0.00718865, "sell": 0.006407884093550591},
  425. )
  426. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  427. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  428. result = strategy.on_tick({})
  429. assert result["action"] == "hold"
  430. assert ctx.cancelled_all == 0
  431. def test_grid_skips_rebuild_when_balance_refresh_fails(monkeypatch):
  432. class FakeContext:
  433. base_currency = "XRP"
  434. counter_currency = "USD"
  435. market_symbol = "xrpusd"
  436. minimum_order_value = 10.0
  437. mode = "active"
  438. def __init__(self):
  439. self.cancelled_all = 0
  440. self.placed_orders = []
  441. def get_fee_rates(self, market):
  442. return {"maker": 0.0, "taker": 0.004}
  443. def get_account_info(self):
  444. raise RuntimeError("Bitstamp auth breaker active, retry later")
  445. def cancel_all_orders(self):
  446. self.cancelled_all += 1
  447. return {"ok": True}
  448. def suggest_order_amount(self, **kwargs):
  449. return 10.0
  450. def place_order(self, **kwargs):
  451. self.placed_orders.append(kwargs)
  452. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  453. ctx = FakeContext()
  454. strategy = GridStrategy(ctx, {"grid_levels": 5, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  455. strategy.state["center_price"] = 1.4397
  456. strategy.state["seeded"] = True
  457. strategy.state["orders"] = [{"side": "buy", "price": 1.43, "amount": 10.0, "id": "o1"}]
  458. strategy.state["order_ids"] = ["o1"]
  459. monkeypatch.setattr(strategy, "_sync_open_orders_state", lambda: [{"side": "buy", "price": 1.43, "amount": 10.0, "id": "o1"}])
  460. monkeypatch.setattr(strategy, "_price", lambda: 1.4397)
  461. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  462. result = strategy.on_tick({})
  463. assert result["action"] == "hold"
  464. assert result["reason"] == "balance refresh unavailable"
  465. assert ctx.cancelled_all == 0
  466. assert ctx.placed_orders == []
  467. def test_grid_shape_check_uses_initial_balance_snapshot_without_extra_reads(monkeypatch):
  468. class FakeContext:
  469. base_currency = "XRP"
  470. counter_currency = "USD"
  471. market_symbol = "xrpusd"
  472. minimum_order_value = 10.0
  473. mode = "active"
  474. def __init__(self):
  475. self.cancelled_all = 0
  476. self.placed_orders = []
  477. self.calls = 0
  478. def get_fee_rates(self, market):
  479. return {"maker": 0.0, "taker": 0.004}
  480. def get_account_info(self):
  481. self.calls += 1
  482. if self.calls == 1:
  483. return {
  484. "balances": [
  485. {"asset_code": "USD", "available": 41.29},
  486. {"asset_code": "XRP", "available": 9.98954},
  487. ]
  488. }
  489. raise RuntimeError("Bitstamp auth breaker active, retry later")
  490. def cancel_all_orders(self):
  491. self.cancelled_all += 1
  492. return {"ok": True}
  493. def suggest_order_amount(self, **kwargs):
  494. return 10.0
  495. def place_order(self, **kwargs):
  496. self.placed_orders.append(kwargs)
  497. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  498. ctx = FakeContext()
  499. strategy = GridStrategy(ctx, {"grid_levels": 2, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  500. strategy.state["center_price"] = 1.3285
  501. strategy.state["seeded"] = True
  502. strategy.state["orders"] = [
  503. {"side": "buy", "price": 1.3243993, "amount": 7.63, "id": "existing-buy"},
  504. {"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"},
  505. {"side": "sell", "price": 1.3367011, "amount": 9.0, "id": "sell-2"},
  506. ]
  507. strategy.state["order_ids"] = ["existing-buy", "sell-1", "sell-2"]
  508. def fake_sync_open_orders_state():
  509. live = [{"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"}]
  510. strategy.state["orders"] = live
  511. strategy.state["order_ids"] = ["sell-1"]
  512. strategy.state["open_order_count"] = 1
  513. return live
  514. rebuilds = {"count": 0}
  515. def fake_rebuild(price, reason):
  516. rebuilds["count"] += 1
  517. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  518. monkeypatch.setattr(strategy, "_recenter_and_rebuild_from_price", fake_rebuild)
  519. monkeypatch.setattr(strategy, "_price", lambda: 1.3285)
  520. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  521. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  522. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  523. result = strategy.on_tick({})
  524. assert result["action"] == "reseed"
  525. assert ctx.calls == 1
  526. assert rebuilds["count"] == 1
  527. assert ctx.cancelled_all == 0
  528. assert ctx.placed_orders == []
  529. def test_grid_missing_order_triggers_full_rebuild(monkeypatch):
  530. class FakeContext:
  531. base_currency = "XRP"
  532. counter_currency = "USD"
  533. market_symbol = "xrpusd"
  534. minimum_order_value = 10.0
  535. mode = "active"
  536. def __init__(self):
  537. self.cancelled_all = 0
  538. self.placed_orders = []
  539. def get_fee_rates(self, market):
  540. return {"maker": 0.0, "taker": 0.004}
  541. def get_account_info(self):
  542. return {
  543. "balances": [
  544. {"asset_code": "USD", "available": 13.55},
  545. {"asset_code": "XRP", "available": 22.0103},
  546. ]
  547. }
  548. def suggest_order_amount(
  549. self,
  550. *,
  551. side,
  552. price,
  553. levels,
  554. min_notional,
  555. fee_rate,
  556. max_notional_per_order=0.0,
  557. dust_collect=False,
  558. order_size=0.0,
  559. safety=0.995,
  560. ):
  561. if side == "buy":
  562. quote_available = 13.55
  563. spendable_quote = quote_available * safety
  564. quote_cap = min(spendable_quote, max_notional_per_order) if max_notional_per_order > 0 else spendable_quote
  565. if quote_cap < min_notional * (1 + fee_rate):
  566. return 0.0
  567. return quote_cap / (price * (1 + fee_rate))
  568. return 0.0
  569. def cancel_all_orders(self):
  570. self.cancelled_all += 1
  571. return {"ok": True}
  572. def place_order(self, **kwargs):
  573. self.placed_orders.append(kwargs)
  574. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  575. ctx = FakeContext()
  576. strategy = GridStrategy(
  577. ctx,
  578. {
  579. "grid_levels": 2,
  580. "grid_step_pct": 0.0062,
  581. "grid_step_min_pct": 0.0033,
  582. "grid_step_max_pct": 0.012,
  583. "max_notional_per_order": 12,
  584. "order_call_delay_ms": 0,
  585. "trade_sides": "both",
  586. "debug_orders": True,
  587. "dust_collect": True,
  588. "enable_trend_guard": False,
  589. "fee_rate": 0.004,
  590. },
  591. )
  592. strategy.state["center_price"] = 1.3285
  593. strategy.state["seeded"] = True
  594. strategy.state["base_available"] = 22.0103
  595. strategy.state["counter_available"] = 13.55
  596. strategy.state["orders"] = [
  597. {"side": "buy", "price": 1.3243993, "amount": 7.63, "id": "existing-buy"},
  598. {"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"},
  599. {"side": "sell", "price": 1.3367011, "amount": 9.0, "id": "sell-2"},
  600. ]
  601. strategy.state["order_ids"] = ["existing-buy", "sell-1", "sell-2"]
  602. def fake_sync_open_orders_state():
  603. live = [{"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"}]
  604. strategy.state["orders"] = live
  605. strategy.state["order_ids"] = ["sell-1"]
  606. strategy.state["open_order_count"] = 1
  607. return live
  608. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  609. monkeypatch.setattr(strategy, "_price", lambda: 1.3285)
  610. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  611. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  612. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  613. result = strategy.on_tick({})
  614. assert result["action"] in {"seed", "reseed"}
  615. assert ctx.cancelled_all == 1
  616. assert len(ctx.placed_orders) > 0
  617. assert strategy.state["last_action"] == "reseeded"
  618. def test_grid_side_imbalance_triggers_full_rebuild(monkeypatch):
  619. class FakeContext:
  620. base_currency = "XRP"
  621. counter_currency = "USD"
  622. market_symbol = "xrpusd"
  623. minimum_order_value = 10.0
  624. mode = "active"
  625. def __init__(self):
  626. self.cancelled_all = 0
  627. self.placed_orders = []
  628. def get_fee_rates(self, market):
  629. return {"maker": 0.0, "taker": 0.004}
  630. def get_account_info(self):
  631. return {"balances": [{"asset_code": "USD", "available": 41.29}, {"asset_code": "XRP", "available": 9.98954}]}
  632. def cancel_all_orders(self):
  633. self.cancelled_all += 1
  634. return {"ok": True}
  635. def suggest_order_amount(self, **kwargs):
  636. return 10.0
  637. def place_order(self, **kwargs):
  638. self.placed_orders.append(kwargs)
  639. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  640. ctx = FakeContext()
  641. strategy = GridStrategy(ctx, {"grid_levels": 2, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  642. strategy.state["center_price"] = 1.3907
  643. strategy.state["seeded"] = True
  644. strategy.state["base_available"] = 9.98954
  645. strategy.state["counter_available"] = 41.29
  646. strategy.state["orders"] = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": "o1"} for _ in range(5)]
  647. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  648. def fake_sync_open_orders_state():
  649. live = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": f"o{i}"} for i in range(5)]
  650. strategy.state["orders"] = live
  651. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  652. strategy.state["open_order_count"] = 5
  653. return live
  654. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  655. monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: True)
  656. monkeypatch.setattr(strategy, "_price", lambda: 1.3915)
  657. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  658. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  659. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  660. result = strategy.on_tick({})
  661. assert result["action"] in {"seed", "reseed"}
  662. assert ctx.cancelled_all == 1
  663. assert len(ctx.placed_orders) > 0
  664. def test_grid_recenters_exactly_on_live_price():
  665. class FakeContext:
  666. base_currency = "XRP"
  667. counter_currency = "USD"
  668. market_symbol = "xrpusd"
  669. minimum_order_value = 10.0
  670. mode = "active"
  671. def cancel_all_orders(self):
  672. return {"ok": True}
  673. def cancel_all_orders_confirmed(self):
  674. return {"conclusive": True, "error": None, "cleanup_status": "cleanup_confirmed", "cancelled_order_ids": []}
  675. def get_fee_rates(self, market):
  676. return {"maker": 0.0, "taker": 0.0}
  677. def suggest_order_amount(self, **kwargs):
  678. return 0.1
  679. def place_order(self, **kwargs):
  680. return {"status": "ok", "id": "oid-1"}
  681. strategy = GridStrategy(FakeContext(), {})
  682. strategy.state["center_price"] = 100.0
  683. strategy._recenter_and_rebuild_from_price(160.0, "test recenter")
  684. assert strategy.state["center_price"] == 160.0
  685. def test_grid_recenter_preserves_tracked_orders_when_cancel_is_inconclusive():
  686. class FakeContext:
  687. base_currency = "XRP"
  688. counter_currency = "USD"
  689. market_symbol = "xrpusd"
  690. minimum_order_value = 10.0
  691. mode = "active"
  692. def __init__(self):
  693. self.placed_orders = []
  694. def cancel_all_orders_confirmed(self):
  695. return {
  696. "conclusive": False,
  697. "error": "auth breaker active",
  698. "cleanup_status": "cleanup_partial",
  699. "cancelled_order_ids": ["o1"],
  700. }
  701. def get_fee_rates(self, market):
  702. return {"maker": 0.0, "taker": 0.0}
  703. def suggest_order_amount(self, **kwargs):
  704. return 0.1
  705. def place_order(self, **kwargs):
  706. self.placed_orders.append(kwargs)
  707. return {"status": "ok", "id": "oid-1"}
  708. ctx = FakeContext()
  709. strategy = GridStrategy(ctx, {})
  710. strategy.state["center_price"] = 100.0
  711. strategy.state["orders"] = [{"id": "o1"}, {"id": "o2"}]
  712. strategy.state["order_ids"] = ["o1", "o2"]
  713. strategy.state["open_order_count"] = 2
  714. strategy._recenter_and_rebuild_from_price(160.0, "test recenter")
  715. assert strategy.state["center_price"] == 100.0
  716. assert strategy.state["orders"] == [{"id": "o2"}]
  717. assert strategy.state["order_ids"] == ["o2"]
  718. assert strategy.state["open_order_count"] == 1
  719. assert strategy.state["cleanup_status"] == "cleanup_partial"
  720. assert strategy.state["last_action"] == "test recenter cleanup pending"
  721. assert ctx.placed_orders == []
  722. def test_grid_stop_cancels_all_open_orders():
  723. class FakeContext:
  724. base_currency = "XRP"
  725. counter_currency = "USD"
  726. market_symbol = "xrpusd"
  727. minimum_order_value = 10.0
  728. mode = "active"
  729. def __init__(self):
  730. self.cancelled = False
  731. def cancel_all_orders(self):
  732. self.cancelled = True
  733. return {"ok": True}
  734. def cancel_all_orders_confirmed(self):
  735. self.cancelled = True
  736. return {"conclusive": True, "error": None, "cleanup_status": "cleanup_confirmed", "cancelled_order_ids": ["o1"]}
  737. def get_fee_rates(self, market):
  738. return {"maker": 0.0, "taker": 0.0}
  739. strategy = GridStrategy(FakeContext(), {})
  740. strategy.state["orders"] = [{"id": "o1"}]
  741. strategy.state["order_ids"] = ["o1"]
  742. strategy.state["open_order_count"] = 1
  743. strategy.on_stop()
  744. assert strategy.context.cancelled is True
  745. assert strategy.state["open_order_count"] == 0
  746. assert strategy.state["cleanup_status"] == "cleanup_confirmed"
  747. assert strategy.state["last_action"] == "stopped"
  748. def test_grid_stop_preserves_tracked_orders_when_cancel_is_inconclusive():
  749. class FakeContext:
  750. base_currency = "XRP"
  751. counter_currency = "USD"
  752. market_symbol = "xrpusd"
  753. minimum_order_value = 10.0
  754. mode = "active"
  755. def cancel_all_orders_confirmed(self):
  756. return {
  757. "conclusive": False,
  758. "error": "auth breaker active",
  759. "cleanup_status": "cleanup_failed",
  760. "cancelled_order_ids": [],
  761. }
  762. def get_fee_rates(self, market):
  763. return {"maker": 0.0, "taker": 0.0}
  764. strategy = GridStrategy(FakeContext(), {})
  765. strategy.state["orders"] = [{"id": "o1"}]
  766. strategy.state["order_ids"] = ["o1"]
  767. strategy.state["open_order_count"] = 1
  768. strategy.on_stop()
  769. assert strategy.state["orders"] == [{"id": "o1"}]
  770. assert strategy.state["order_ids"] == ["o1"]
  771. assert strategy.state["open_order_count"] == 1
  772. assert strategy.state["cleanup_status"] == "cleanup_failed"
  773. assert strategy.state["last_action"] == "stop cleanup pending"
  774. assert strategy.state["last_error"] == "auth breaker active"
  775. def test_grid_observe_mode_preserves_tracked_orders_when_cancel_is_inconclusive(monkeypatch):
  776. class FakeContext:
  777. base_currency = "XRP"
  778. counter_currency = "USD"
  779. market_symbol = "xrpusd"
  780. minimum_order_value = 10.0
  781. mode = "observe"
  782. def cancel_all_orders_confirmed(self):
  783. return {
  784. "conclusive": False,
  785. "error": "auth breaker active",
  786. "cleanup_status": "cleanup_failed",
  787. "cancelled_order_ids": [],
  788. }
  789. def get_fee_rates(self, market):
  790. return {"maker": 0.0, "taker": 0.0}
  791. def get_account_info(self):
  792. return {"balances": [{"asset_code": "USD", "available": 50.0}, {"asset_code": "XRP", "available": 5.0}]}
  793. strategy = GridStrategy(FakeContext(), {})
  794. strategy.state["center_price"] = 1.42
  795. strategy.state["seeded"] = True
  796. strategy.state["orders"] = [{"id": "o1", "side": "buy", "price": 1.4, "amount": 10.0}]
  797. strategy.state["order_ids"] = ["o1"]
  798. strategy.state["open_order_count"] = 1
  799. monkeypatch.setattr(strategy, "_price", lambda: 1.42)
  800. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  801. monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: True)
  802. monkeypatch.setattr(strategy, "_sync_open_orders_state", lambda: [{"id": "o1", "side": "buy", "price": 1.4, "amount": 10.0}])
  803. result = strategy.on_tick({})
  804. assert result["action"] == "observe"
  805. assert result["cleanup_pending"] is True
  806. assert strategy.state["orders"] == [{"id": "o1", "side": "buy", "price": 1.4, "amount": 10.0}]
  807. assert strategy.state["order_ids"] == ["o1"]
  808. assert strategy.state["open_order_count"] == 1
  809. assert strategy.state["cleanup_status"] == "cleanup_failed"
  810. assert strategy.state["last_action"] == "observe cleanup pending"
  811. assert strategy.state["last_error"] == "auth breaker active"
  812. def test_base_strategy_report_uses_context_snapshot():
  813. class FakeContext:
  814. id = "s-1"
  815. account_id = "acct-1"
  816. market_symbol = "xrpusd"
  817. base_currency = "XRP"
  818. counter_currency = "USD"
  819. mode = "active"
  820. def get_strategy_snapshot(self):
  821. return {
  822. "identity": {"strategy_id": "s-1", "strategy_name": "Demo", "account_id": "acct-1", "market": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"},
  823. "control": {"enabled_state": "on", "mode": "active"},
  824. "position": {"balances": [{"asset_code": "XRP", "available": 1.0}]},
  825. "orders": {"open_orders": [{"id": "o1"}]},
  826. "execution": {"execution_quality": "good"},
  827. }
  828. class DemoStrategy(BaseStrategy):
  829. LABEL = "Demo"
  830. report = DemoStrategy(FakeContext(), {}).report()
  831. assert report["identity"]["strategy_id"] == "s-1"
  832. assert report["control"]["mode"] == "active"
  833. assert report["position"]["open_orders"][0]["id"] == "o1"
  834. def test_dumb_trader_uses_policy_and_reports_fit():
  835. class FakeContext:
  836. id = "s-2"
  837. account_id = "acct-2"
  838. client_id = "cid-2"
  839. mode = "active"
  840. market_symbol = "xrpusd"
  841. base_currency = "XRP"
  842. counter_currency = "USD"
  843. def get_price(self, symbol):
  844. return {"price": 1.2}
  845. def place_order(self, **kwargs):
  846. return {"ok": True, "order": kwargs}
  847. def get_strategy_snapshot(self):
  848. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  849. strat = DumbStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 1.5})
  850. strat.apply_policy()
  851. report = strat.report()
  852. assert report["fit"]["risk_profile"] == "neutral"
  853. assert strat.state["policy_derived"]["order_notional_quote"] > 0
  854. def test_dumb_trader_buys_on_configured_side_without_regime_input():
  855. class FakeContext:
  856. id = "s-bull"
  857. account_id = "acct-1"
  858. client_id = "cid-1"
  859. mode = "active"
  860. market_symbol = "xrpusd"
  861. base_currency = "XRP"
  862. counter_currency = "USD"
  863. def __init__(self):
  864. self.orders = []
  865. def get_price(self, symbol):
  866. return {"price": 1.2}
  867. def place_order(self, **kwargs):
  868. self.orders.append(kwargs)
  869. return {"ok": True, "order": kwargs}
  870. def get_account_info(self):
  871. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  872. minimum_order_value = 10.0
  873. def suggest_order_amount(self, **kwargs):
  874. return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
  875. def get_strategy_snapshot(self):
  876. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  877. ctx = FakeContext()
  878. strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 20.0})
  879. result = strat.on_tick({})
  880. assert result["action"] == "buy"
  881. assert ctx.orders[-1]["side"] == "buy"
  882. assert ctx.orders[-1]["amount"] == 20.0 / 1.2
  883. assert strat.state["last_action"] == "buy_dumb"
  884. def test_dumb_trader_sells_on_configured_side_without_regime_input():
  885. class FakeContext:
  886. id = "s-bear"
  887. account_id = "acct-1"
  888. client_id = "cid-1"
  889. mode = "active"
  890. market_symbol = "xrpusd"
  891. base_currency = "XRP"
  892. counter_currency = "USD"
  893. def __init__(self):
  894. self.orders = []
  895. def get_price(self, symbol):
  896. return {"price": 1.2}
  897. def place_order(self, **kwargs):
  898. self.orders.append(kwargs)
  899. return {"ok": True, "order": kwargs}
  900. def get_account_info(self):
  901. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 10}]}
  902. minimum_order_value = 10.0
  903. def suggest_order_amount(self, **kwargs):
  904. return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
  905. def get_strategy_snapshot(self):
  906. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  907. ctx = FakeContext()
  908. strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 20.0})
  909. result = strat.on_tick({})
  910. assert result["action"] == "sell"
  911. assert ctx.orders[-1]["side"] == "sell"
  912. assert ctx.orders[-1]["amount"] == 20.0 / 1.2
  913. assert strat.state["last_action"] == "sell_dumb"
  914. def test_dumb_trader_buy_only_ignores_bear_regime():
  915. class FakeContext:
  916. id = "s-buy-only"
  917. account_id = "acct-1"
  918. client_id = "cid-1"
  919. mode = "active"
  920. market_symbol = "xrpusd"
  921. base_currency = "XRP"
  922. counter_currency = "USD"
  923. def __init__(self):
  924. self.orders = []
  925. def get_price(self, symbol):
  926. return {"price": 1.2}
  927. def get_regime(self, symbol, timeframe="1h"):
  928. return {
  929. "trend": {"state": "bear", "ema_fast": 1.17, "ema_slow": 1.2},
  930. "momentum": {"state": "bear", "rsi": 36, "macd_histogram": -0.002},
  931. }
  932. def place_order(self, **kwargs):
  933. self.orders.append(kwargs)
  934. return {"ok": True, "order": kwargs}
  935. def get_account_info(self):
  936. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 10}]}
  937. minimum_order_value = 10.0
  938. def suggest_order_amount(self, **kwargs):
  939. return 10.0
  940. def get_strategy_snapshot(self):
  941. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  942. ctx = FakeContext()
  943. strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 20.0})
  944. result = strat.on_tick({})
  945. assert result["action"] == "buy"
  946. assert ctx.orders[-1]["side"] == "buy"
  947. assert strat.state["last_action"] == "buy_dumb"
  948. def test_dumb_trader_sell_only_ignores_bull_regime():
  949. class FakeContext:
  950. id = "s-sell-only"
  951. account_id = "acct-1"
  952. client_id = "cid-1"
  953. mode = "active"
  954. market_symbol = "xrpusd"
  955. base_currency = "XRP"
  956. counter_currency = "USD"
  957. def __init__(self):
  958. self.orders = []
  959. def get_price(self, symbol):
  960. return {"price": 1.2}
  961. def get_regime(self, symbol, timeframe="1h"):
  962. return {
  963. "trend": {"state": "bull", "ema_fast": 1.21, "ema_slow": 1.18},
  964. "momentum": {"state": "bull", "rsi": 64, "macd_histogram": 0.002},
  965. }
  966. def place_order(self, **kwargs):
  967. self.orders.append(kwargs)
  968. return {"ok": True, "order": kwargs}
  969. def get_account_info(self):
  970. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  971. minimum_order_value = 10.0
  972. def suggest_order_amount(self, **kwargs):
  973. return 10.0
  974. def get_strategy_snapshot(self):
  975. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  976. ctx = FakeContext()
  977. strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 20.0})
  978. result = strat.on_tick({})
  979. assert result["action"] == "sell"
  980. assert ctx.orders[-1]["side"] == "sell"
  981. assert strat.state["last_action"] == "sell_dumb"
  982. def test_dumb_trader_policy_does_not_override_explicit_order_notional_quote():
  983. class FakeContext:
  984. id = "s-explicit"
  985. account_id = "acct-1"
  986. client_id = "cid-1"
  987. mode = "active"
  988. market_symbol = "xrpusd"
  989. base_currency = "XRP"
  990. counter_currency = "USD"
  991. def get_price(self, symbol):
  992. return {"price": 1.2}
  993. def get_strategy_snapshot(self):
  994. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  995. strat = DumbStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 10.5})
  996. strat.apply_policy()
  997. assert strat.config["order_notional_quote"] == 10.5
  998. assert strat.state["policy_derived"]["order_notional_quote"] == 10.5
  999. def test_dumb_trader_passes_live_fee_rate_into_sizing_helper():
  1000. class FakeContext:
  1001. id = "s-fee"
  1002. account_id = "acct-1"
  1003. client_id = "cid-1"
  1004. mode = "active"
  1005. market_symbol = "xrpusd"
  1006. base_currency = "XRP"
  1007. counter_currency = "USD"
  1008. minimum_order_value = 10.0
  1009. def __init__(self):
  1010. self.fee_calls = []
  1011. self.suggest_calls = []
  1012. def get_price(self, symbol):
  1013. return {"price": 1.2}
  1014. def get_fee_rates(self, market_symbol=None):
  1015. self.fee_calls.append(market_symbol)
  1016. return {"maker": 0.0025, "taker": 0.004}
  1017. def suggest_order_amount(self, **kwargs):
  1018. self.suggest_calls.append(kwargs)
  1019. return 8.0
  1020. def place_order(self, **kwargs):
  1021. return {"ok": True, "order": kwargs}
  1022. def get_account_info(self):
  1023. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  1024. def get_strategy_snapshot(self):
  1025. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1026. ctx = FakeContext()
  1027. strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 10.5, "dust_collect": True})
  1028. strat.on_tick({})
  1029. assert ctx.fee_calls == ["xrpusd"]
  1030. assert ctx.suggest_calls[-1]["fee_rate"] == 0.0025
  1031. assert ctx.suggest_calls[-1]["dust_collect"] is True
  1032. def test_grid_sizing_helper_receives_quote_controls_and_dust_collect():
  1033. class FakeContext:
  1034. base_currency = "XRP"
  1035. counter_currency = "USD"
  1036. market_symbol = "xrpusd"
  1037. minimum_order_value = 10.0
  1038. mode = "active"
  1039. def __init__(self):
  1040. self.suggest_calls = []
  1041. def get_fee_rates(self, market_symbol=None):
  1042. return {"maker": 0.001, "taker": 0.004}
  1043. def suggest_order_amount(self, **kwargs):
  1044. self.suggest_calls.append(kwargs)
  1045. return 7.0
  1046. ctx = FakeContext()
  1047. strategy = GridStrategy(
  1048. ctx,
  1049. {
  1050. "grid_levels": 3,
  1051. "order_notional_quote": 11.0,
  1052. "max_order_notional_quote": 12.0,
  1053. "dust_collect": True,
  1054. },
  1055. )
  1056. amount = strategy._suggest_amount("buy", 1.5, 3, 10.0)
  1057. assert amount == 7.0
  1058. assert ctx.suggest_calls[-1]["fee_rate"] == 0.004
  1059. assert ctx.suggest_calls[-1]["quote_notional"] == 11.0
  1060. assert ctx.suggest_calls[-1]["max_notional_per_order"] == 12.0
  1061. assert ctx.suggest_calls[-1]["dust_collect"] is True
  1062. assert ctx.suggest_calls[-1]["levels"] == 3
  1063. def test_dumb_trader_buy_uses_requested_notional_even_with_balance_target_configured():
  1064. class FakeContext:
  1065. id = "s-buy-clamp"
  1066. account_id = "acct-1"
  1067. client_id = "cid-1"
  1068. mode = "active"
  1069. market_symbol = "xrpusd"
  1070. base_currency = "XRP"
  1071. counter_currency = "USD"
  1072. minimum_order_value = 0.5
  1073. def __init__(self):
  1074. self.orders = []
  1075. def get_price(self, symbol):
  1076. return {"price": 1.0}
  1077. def get_fee_rates(self, market_symbol=None):
  1078. return {"maker": 0.0, "taker": 0.0}
  1079. def suggest_order_amount(self, **kwargs):
  1080. return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
  1081. def place_order(self, **kwargs):
  1082. self.orders.append(kwargs)
  1083. return {"ok": True, "order": kwargs}
  1084. def get_account_info(self):
  1085. return {"balances": [{"asset_code": "USD", "available": 6.0}, {"asset_code": "XRP", "available": 4.0}]}
  1086. def get_strategy_snapshot(self):
  1087. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1088. ctx = FakeContext()
  1089. strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 3.0, "balance_target": 0.5})
  1090. result = strat.on_tick({})
  1091. assert result["action"] == "buy"
  1092. assert ctx.orders[-1]["amount"] == 3.0
  1093. def test_dumb_trader_sell_uses_requested_notional_even_with_balance_target_configured():
  1094. class FakeContext:
  1095. id = "s-sell-clamp"
  1096. account_id = "acct-1"
  1097. client_id = "cid-1"
  1098. mode = "active"
  1099. market_symbol = "xrpusd"
  1100. base_currency = "XRP"
  1101. counter_currency = "USD"
  1102. minimum_order_value = 0.5
  1103. def __init__(self):
  1104. self.orders = []
  1105. def get_price(self, symbol):
  1106. return {"price": 1.0}
  1107. def get_fee_rates(self, market_symbol=None):
  1108. return {"maker": 0.0, "taker": 0.0}
  1109. def suggest_order_amount(self, **kwargs):
  1110. return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
  1111. def place_order(self, **kwargs):
  1112. self.orders.append(kwargs)
  1113. return {"ok": True, "order": kwargs}
  1114. def get_account_info(self):
  1115. return {"balances": [{"asset_code": "USD", "available": 3.0}, {"asset_code": "XRP", "available": 7.0}]}
  1116. def get_strategy_snapshot(self):
  1117. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1118. ctx = FakeContext()
  1119. strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 5.0, "balance_target": 0.5})
  1120. result = strat.on_tick({})
  1121. assert result["action"] == "sell"
  1122. assert ctx.orders[-1]["amount"] == 5.0
  1123. def test_dumb_trader_sell_holds_sub_minimum_order():
  1124. class FakeContext:
  1125. id = "s-sell-min"
  1126. account_id = "acct-1"
  1127. client_id = "cid-1"
  1128. mode = "active"
  1129. market_symbol = "solusd"
  1130. base_currency = "SOL"
  1131. counter_currency = "USD"
  1132. minimum_order_value = 1.0
  1133. def __init__(self):
  1134. self.orders = []
  1135. def get_price(self, symbol):
  1136. return {"price": 86.20062}
  1137. def get_fee_rates(self, market_symbol=None):
  1138. return {"maker": 0.0, "taker": 0.0}
  1139. def suggest_order_amount(self, **kwargs):
  1140. return 0.001
  1141. def place_order(self, **kwargs):
  1142. self.orders.append(kwargs)
  1143. return {"ok": True, "order": kwargs}
  1144. def get_account_info(self):
  1145. return {"balances": [{"asset_code": "USD", "available": 1000.0}, {"asset_code": "SOL", "available": 0.00447}]}
  1146. def get_strategy_snapshot(self):
  1147. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1148. ctx = FakeContext()
  1149. strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 5.0, "balance_target": 1.0})
  1150. result = strat.on_tick({})
  1151. assert result["action"] == "hold"
  1152. assert result["reason"] == "no usable size"
  1153. assert ctx.orders == []
  1154. def test_dumb_trader_holds_when_live_sizing_reports_no_affordable_sell():
  1155. class FakeContext:
  1156. id = "s-sell-no-funds"
  1157. account_id = "acct-1"
  1158. client_id = "cid-1"
  1159. mode = "active"
  1160. market_symbol = "solusd"
  1161. base_currency = "SOL"
  1162. counter_currency = "USD"
  1163. minimum_order_value = 0.5
  1164. def __init__(self):
  1165. self.orders = []
  1166. def get_price(self, symbol):
  1167. return {"price": 86.69}
  1168. def get_fee_rates(self, market_symbol=None):
  1169. return {"maker": 0.0, "taker": 0.0}
  1170. def suggest_order_amount(self, **kwargs):
  1171. return 0.0
  1172. def place_order(self, **kwargs):
  1173. self.orders.append(kwargs)
  1174. return {"ok": True, "order": kwargs}
  1175. def get_account_info(self):
  1176. return {"balances": [{"asset_code": "USD", "available": 1000.0}, {"asset_code": "SOL", "available": 0.00447}]}
  1177. def get_strategy_snapshot(self):
  1178. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1179. ctx = FakeContext()
  1180. strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 11.0, "dust_collect": True})
  1181. result = strat.on_tick({})
  1182. assert result["action"] == "hold"
  1183. assert result["reason"] == "no usable size"
  1184. assert ctx.orders == []
  1185. def test_dumb_trader_holds_when_balance_refresh_fails_after_restart():
  1186. class FakeContext:
  1187. id = "s-restart-hold"
  1188. account_id = "acct-1"
  1189. client_id = "cid-1"
  1190. mode = "active"
  1191. market_symbol = "solusd"
  1192. base_currency = "SOL"
  1193. counter_currency = "USD"
  1194. minimum_order_value = 1.0
  1195. def __init__(self):
  1196. self.orders = []
  1197. def get_price(self, symbol):
  1198. return {"price": 86.51}
  1199. def get_fee_rates(self, market_symbol=None):
  1200. return {"maker": 0.0, "taker": 0.0}
  1201. def get_account_info(self):
  1202. raise RuntimeError("Bitstamp auth breaker active, retry later")
  1203. def suggest_order_amount(self, **kwargs):
  1204. raise AssertionError("should not size an order after balance refresh failure")
  1205. def place_order(self, **kwargs):
  1206. self.orders.append(kwargs)
  1207. return {"ok": True, "order": kwargs}
  1208. def get_strategy_snapshot(self):
  1209. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1210. ctx = FakeContext()
  1211. strat = DumbStrategy(
  1212. ctx,
  1213. {
  1214. "trade_side": "sell",
  1215. "order_notional_quote": 5.0,
  1216. "dust_collect": True,
  1217. },
  1218. )
  1219. strat.state["base_available"] = 0.127153
  1220. strat.state["counter_available"] = 0.0
  1221. result = strat.on_tick({})
  1222. assert result["action"] == "hold"
  1223. assert result["reason"] == "balance refresh unavailable"
  1224. assert strat.state["balance_snapshot_ok"] is False
  1225. assert strat.state["base_available"] == 0.0
  1226. assert ctx.orders == []
  1227. def test_dumb_trader_holds_when_trade_side_is_symmetrical():
  1228. class FakeContext:
  1229. id = "s-target-hold"
  1230. account_id = "acct-1"
  1231. client_id = "cid-1"
  1232. mode = "active"
  1233. market_symbol = "xrpusd"
  1234. base_currency = "XRP"
  1235. counter_currency = "USD"
  1236. minimum_order_value = 0.5
  1237. def get_price(self, symbol):
  1238. return {"price": 1.0}
  1239. def get_fee_rates(self, market_symbol=None):
  1240. return {"maker": 0.0, "taker": 0.0}
  1241. def suggest_order_amount(self, **kwargs):
  1242. return 10.0
  1243. def get_account_info(self):
  1244. return {"balances": [{"asset_code": "USD", "available": 5.0}, {"asset_code": "XRP", "available": 5.0}]}
  1245. def get_strategy_snapshot(self):
  1246. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1247. strat = DumbStrategy(FakeContext(), {"trade_side": "both", "order_notional_quote": 3.0, "balance_target": 0.5})
  1248. result = strat.on_tick({})
  1249. assert result["action"] == "hold"
  1250. assert result["reason"] == "trade_side must be buy or sell"
  1251. def test_cap_amount_to_balance_target_caps_sell_to_live_base():
  1252. amount = cap_amount_to_balance_target(
  1253. suggested_amount=0.127226,
  1254. side="sell",
  1255. price=86.20062,
  1256. fee_rate=0.0,
  1257. balance_target=1.0,
  1258. base_available=0.00447,
  1259. counter_available=0.0,
  1260. min_notional=0.0,
  1261. )
  1262. assert amount == 0.00447
  1263. def test_cap_amount_to_balance_target_rejects_sell_below_min_notional():
  1264. amount = cap_amount_to_balance_target(
  1265. suggested_amount=0.127226,
  1266. side="sell",
  1267. price=86.20062,
  1268. fee_rate=0.0,
  1269. balance_target=1.0,
  1270. base_available=0.00447,
  1271. counter_available=0.0,
  1272. min_notional=1.0,
  1273. )
  1274. assert amount == 0.0
  1275. def test_dumb_trader_ignores_balance_target_and_keeps_size():
  1276. class FakeContext:
  1277. id = "s-target-open"
  1278. account_id = "acct-1"
  1279. client_id = "cid-1"
  1280. mode = "active"
  1281. market_symbol = "xrpusd"
  1282. base_currency = "XRP"
  1283. counter_currency = "USD"
  1284. minimum_order_value = 0.5
  1285. def __init__(self):
  1286. self.orders = []
  1287. def get_price(self, symbol):
  1288. return {"price": 1.0}
  1289. def get_fee_rates(self, market_symbol=None):
  1290. return {"maker": 0.0, "taker": 0.0}
  1291. def suggest_order_amount(self, **kwargs):
  1292. return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
  1293. def place_order(self, **kwargs):
  1294. self.orders.append(kwargs)
  1295. return {"ok": True, "order": kwargs}
  1296. def get_account_info(self):
  1297. return {"balances": [{"asset_code": "USD", "available": 6.0}, {"asset_code": "XRP", "available": 4.0}]}
  1298. def get_strategy_snapshot(self):
  1299. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1300. ctx = FakeContext()
  1301. strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 3.0, "balance_target": 0.5})
  1302. result = strat.on_tick({})
  1303. assert result["action"] == "buy"
  1304. assert ctx.orders[-1]["amount"] == 3.0
  1305. def test_exposure_protector_buy_sizing_respects_fee_when_min_order_quote_is_unaffordable():
  1306. class FakeContext:
  1307. account_id = "acct-1"
  1308. client_id = "cid-1"
  1309. mode = "active"
  1310. market_symbol = "xrpusd"
  1311. base_currency = "XRP"
  1312. counter_currency = "USD"
  1313. def get_fee_rates(self, market_symbol=None):
  1314. return {"maker": 0.1, "taker": 0.2}
  1315. strategy = ExposureStrategy(
  1316. FakeContext(),
  1317. {
  1318. "rebalance_target_ratio": 0.9,
  1319. "rebalance_step_ratio": 1.0,
  1320. "balance_tolerance": 0.0,
  1321. "min_order_notional_quote": 10.0,
  1322. },
  1323. )
  1324. strategy.state["base_available"] = 0.0
  1325. strategy.state["counter_available"] = 10.0
  1326. amount = strategy._suggest_amount("buy", 1.0)
  1327. assert amount == 0.0