test_strategies.py 61 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782
  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_keeps_inner_levels_first(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 8.0 if kwargs["side"] == "buy" else 7.31
  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=100.0, quote_total=100.0)
  376. assert plan["counts"]["buy"] == 5
  377. assert plan["counts"]["sell"] == 5
  378. assert [order["level"] for order in plan["buy_orders"]] == [1, 2, 3, 4, 5]
  379. assert [order["level"] for order in plan["sell_orders"]] == [1, 2, 3, 4, 5]
  380. assert plan["buy_skipped"] == []
  381. assert plan["sell_skipped"] == []
  382. def test_grid_plan_truncates_outer_levels_without_skipping_inner_levels(monkeypatch):
  383. class FakeContext:
  384. base_currency = "XRP"
  385. counter_currency = "USD"
  386. market_symbol = "xrpusd"
  387. minimum_order_value = 10.0
  388. mode = "active"
  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": 49.035},
  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 8.0 if kwargs["side"] == "buy" else 7.31
  404. def get_open_orders(self):
  405. return []
  406. ctx = FakeContext()
  407. strategy = GridStrategy(ctx, {"grid_levels": 5, "order_call_delay_ms": 0})
  408. strategy.state["center_price"] = 1.3615
  409. strategy.state["seeded"] = True
  410. strategy.state["orders"] = []
  411. strategy.state["order_ids"] = []
  412. monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: True)
  413. monkeypatch.setattr(strategy, "_price", lambda: 1.3615)
  414. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  415. monkeypatch.setattr(strategy, "_sync_open_orders_state", lambda: [])
  416. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  417. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  418. monkeypatch.setattr(
  419. strategy,
  420. "_effective_grid_steps",
  421. lambda center, **kwargs: {"base": 0.00718865, "buy": 0.00718865, "sell": 0.006407884093550591},
  422. )
  423. plan = strategy._plan_grid(1.3615, base_total=100.0, quote_total=35.0)
  424. assert plan["counts"]["buy"] == 3
  425. assert plan["counts"]["sell"] == 5
  426. assert [order["level"] for order in plan["buy_orders"]] == [1, 2, 3]
  427. assert [order["level"] for order in plan["sell_orders"]] == [1, 2, 3, 4, 5]
  428. assert plan["buy_skipped"] == []
  429. assert plan["sell_skipped"] == []
  430. def test_grid_shape_check_reuses_canonical_plan_without_rebuild(monkeypatch):
  431. class FakeContext:
  432. base_currency = "XRP"
  433. counter_currency = "USD"
  434. market_symbol = "xrpusd"
  435. minimum_order_value = 10.0
  436. mode = "active"
  437. def __init__(self):
  438. self.cancelled_all = 0
  439. def get_fee_rates(self, market):
  440. return {"maker": 0.0, "taker": 0.0}
  441. def get_account_info(self):
  442. return {
  443. "balances": [
  444. {"asset_code": "USD", "available": 40.0},
  445. {"asset_code": "XRP", "available": 40.0},
  446. ]
  447. }
  448. def get_price(self, symbol):
  449. return {"price": 1.3615}
  450. def get_regime(self, symbol, timeframe="1h"):
  451. return {"volatility": {"atr_percent": 0.0}, "trend": {"state": "flat"}}
  452. def suggest_order_amount(self, **kwargs):
  453. return 7.7
  454. def get_open_orders(self):
  455. return [
  456. {"side": "buy", "price": 1.35171265, "amount": 7.7, "id": "b1"},
  457. {"side": "buy", "price": 1.34192531, "amount": 7.7, "id": "b2"},
  458. {"side": "buy", "price": 1.33213796, "amount": 7.7, "id": "b3"},
  459. {"side": "buy", "price": 1.32235061, "amount": 7.7, "id": "b4"},
  460. {"side": "buy", "price": 1.31256327, "amount": 7.7, "id": "b5"},
  461. {"side": "sell", "price": 1.37022433, "amount": 7.7, "id": "s1"},
  462. {"side": "sell", "price": 1.37894867, "amount": 7.7, "id": "s2"},
  463. {"side": "sell", "price": 1.387673, "amount": 7.7, "id": "s3"},
  464. {"side": "sell", "price": 1.39639734, "amount": 7.7, "id": "s4"},
  465. {"side": "sell", "price": 1.40512167, "amount": 7.7, "id": "s5"},
  466. ]
  467. def cancel_all_orders(self):
  468. self.cancelled_all += 1
  469. return {"ok": True}
  470. ctx = FakeContext()
  471. strategy = GridStrategy(ctx, {"grid_levels": 5, "order_call_delay_ms": 0})
  472. strategy.state["center_price"] = 1.3615
  473. strategy.state["seeded"] = True
  474. strategy.state["orders"] = ctx.get_open_orders()
  475. strategy.state["order_ids"] = ["b1", "b2", "b3", "b4", "b5", "s1", "s2", "s3", "s4", "s5"]
  476. monkeypatch.setattr(
  477. strategy,
  478. "_effective_grid_steps",
  479. lambda center, **kwargs: {"base": 0.00718865, "buy": 0.00718865, "sell": 0.006407884093550591},
  480. )
  481. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  482. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  483. result = strategy.on_tick({})
  484. assert result["action"] == "hold"
  485. assert ctx.cancelled_all == 0
  486. def test_grid_skips_rebuild_when_balance_refresh_fails(monkeypatch):
  487. class FakeContext:
  488. base_currency = "XRP"
  489. counter_currency = "USD"
  490. market_symbol = "xrpusd"
  491. minimum_order_value = 10.0
  492. mode = "active"
  493. def __init__(self):
  494. self.cancelled_all = 0
  495. self.placed_orders = []
  496. def get_fee_rates(self, market):
  497. return {"maker": 0.0, "taker": 0.004}
  498. def get_account_info(self):
  499. raise RuntimeError("Bitstamp auth breaker active, retry later")
  500. def cancel_all_orders(self):
  501. self.cancelled_all += 1
  502. return {"ok": True}
  503. def suggest_order_amount(self, **kwargs):
  504. return 10.0
  505. def place_order(self, **kwargs):
  506. self.placed_orders.append(kwargs)
  507. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  508. ctx = FakeContext()
  509. strategy = GridStrategy(ctx, {"grid_levels": 5, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  510. strategy.state["center_price"] = 1.4397
  511. strategy.state["seeded"] = True
  512. strategy.state["orders"] = [{"side": "buy", "price": 1.43, "amount": 10.0, "id": "o1"}]
  513. strategy.state["order_ids"] = ["o1"]
  514. monkeypatch.setattr(strategy, "_sync_open_orders_state", lambda: [{"side": "buy", "price": 1.43, "amount": 10.0, "id": "o1"}])
  515. monkeypatch.setattr(strategy, "_price", lambda: 1.4397)
  516. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  517. result = strategy.on_tick({})
  518. assert result["action"] == "hold"
  519. assert result["reason"] == "balance refresh unavailable"
  520. assert ctx.cancelled_all == 0
  521. assert ctx.placed_orders == []
  522. def test_grid_shape_check_uses_initial_balance_snapshot_without_extra_reads(monkeypatch):
  523. class FakeContext:
  524. base_currency = "XRP"
  525. counter_currency = "USD"
  526. market_symbol = "xrpusd"
  527. minimum_order_value = 10.0
  528. mode = "active"
  529. def __init__(self):
  530. self.cancelled_all = 0
  531. self.placed_orders = []
  532. self.calls = 0
  533. def get_fee_rates(self, market):
  534. return {"maker": 0.0, "taker": 0.004}
  535. def get_account_info(self):
  536. self.calls += 1
  537. if self.calls == 1:
  538. return {
  539. "balances": [
  540. {"asset_code": "USD", "available": 41.29},
  541. {"asset_code": "XRP", "available": 9.98954},
  542. ]
  543. }
  544. raise RuntimeError("Bitstamp auth breaker active, retry later")
  545. def cancel_all_orders(self):
  546. self.cancelled_all += 1
  547. return {"ok": True}
  548. def suggest_order_amount(self, **kwargs):
  549. return 10.0
  550. def place_order(self, **kwargs):
  551. self.placed_orders.append(kwargs)
  552. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  553. ctx = FakeContext()
  554. strategy = GridStrategy(ctx, {"grid_levels": 2, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  555. strategy.state["center_price"] = 1.3285
  556. strategy.state["seeded"] = True
  557. strategy.state["orders"] = [
  558. {"side": "buy", "price": 1.3243993, "amount": 7.63, "id": "existing-buy"},
  559. {"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"},
  560. {"side": "sell", "price": 1.3367011, "amount": 9.0, "id": "sell-2"},
  561. ]
  562. strategy.state["order_ids"] = ["existing-buy", "sell-1", "sell-2"]
  563. def fake_sync_open_orders_state():
  564. live = [{"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"}]
  565. strategy.state["orders"] = live
  566. strategy.state["order_ids"] = ["sell-1"]
  567. strategy.state["open_order_count"] = 1
  568. return live
  569. rebuilds = {"count": 0}
  570. def fake_rebuild(price, reason):
  571. rebuilds["count"] += 1
  572. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  573. monkeypatch.setattr(strategy, "_recenter_and_rebuild_from_price", fake_rebuild)
  574. monkeypatch.setattr(strategy, "_price", lambda: 1.3285)
  575. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  576. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  577. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  578. result = strategy.on_tick({})
  579. assert result["action"] == "reseed"
  580. assert ctx.calls == 1
  581. assert rebuilds["count"] == 1
  582. assert ctx.cancelled_all == 0
  583. assert ctx.placed_orders == []
  584. def test_grid_missing_order_triggers_full_rebuild(monkeypatch):
  585. class FakeContext:
  586. base_currency = "XRP"
  587. counter_currency = "USD"
  588. market_symbol = "xrpusd"
  589. minimum_order_value = 10.0
  590. mode = "active"
  591. def __init__(self):
  592. self.cancelled_all = 0
  593. self.placed_orders = []
  594. def get_fee_rates(self, market):
  595. return {"maker": 0.0, "taker": 0.004}
  596. def get_account_info(self):
  597. return {
  598. "balances": [
  599. {"asset_code": "USD", "available": 13.55},
  600. {"asset_code": "XRP", "available": 22.0103},
  601. ]
  602. }
  603. def suggest_order_amount(
  604. self,
  605. *,
  606. side,
  607. price,
  608. levels,
  609. min_notional,
  610. fee_rate,
  611. max_notional_per_order=0.0,
  612. dust_collect=False,
  613. order_size=0.0,
  614. safety=0.995,
  615. ):
  616. if side == "buy":
  617. quote_available = 13.55
  618. spendable_quote = quote_available * safety
  619. quote_cap = min(spendable_quote, max_notional_per_order) if max_notional_per_order > 0 else spendable_quote
  620. if quote_cap < min_notional * (1 + fee_rate):
  621. return 0.0
  622. return quote_cap / (price * (1 + fee_rate))
  623. return 0.0
  624. def cancel_all_orders(self):
  625. self.cancelled_all += 1
  626. return {"ok": True}
  627. def place_order(self, **kwargs):
  628. self.placed_orders.append(kwargs)
  629. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  630. ctx = FakeContext()
  631. strategy = GridStrategy(
  632. ctx,
  633. {
  634. "grid_levels": 2,
  635. "grid_step_pct": 0.0062,
  636. "grid_step_min_pct": 0.0033,
  637. "grid_step_max_pct": 0.012,
  638. "max_notional_per_order": 12,
  639. "order_call_delay_ms": 0,
  640. "trade_sides": "both",
  641. "debug_orders": True,
  642. "dust_collect": True,
  643. "enable_trend_guard": False,
  644. "fee_rate": 0.004,
  645. },
  646. )
  647. strategy.state["center_price"] = 1.3285
  648. strategy.state["seeded"] = True
  649. strategy.state["base_available"] = 22.0103
  650. strategy.state["counter_available"] = 13.55
  651. strategy.state["orders"] = [
  652. {"side": "buy", "price": 1.3243993, "amount": 7.63, "id": "existing-buy"},
  653. {"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"},
  654. {"side": "sell", "price": 1.3367011, "amount": 9.0, "id": "sell-2"},
  655. ]
  656. strategy.state["order_ids"] = ["existing-buy", "sell-1", "sell-2"]
  657. def fake_sync_open_orders_state():
  658. live = [{"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"}]
  659. strategy.state["orders"] = live
  660. strategy.state["order_ids"] = ["sell-1"]
  661. strategy.state["open_order_count"] = 1
  662. return live
  663. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  664. monkeypatch.setattr(strategy, "_price", lambda: 1.3285)
  665. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  666. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  667. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  668. result = strategy.on_tick({})
  669. assert result["action"] in {"seed", "reseed"}
  670. assert ctx.cancelled_all == 1
  671. assert len(ctx.placed_orders) > 0
  672. assert strategy.state["last_action"] == "reseeded"
  673. def test_grid_side_imbalance_triggers_full_rebuild(monkeypatch):
  674. class FakeContext:
  675. base_currency = "XRP"
  676. counter_currency = "USD"
  677. market_symbol = "xrpusd"
  678. minimum_order_value = 10.0
  679. mode = "active"
  680. def __init__(self):
  681. self.cancelled_all = 0
  682. self.placed_orders = []
  683. def get_fee_rates(self, market):
  684. return {"maker": 0.0, "taker": 0.004}
  685. def get_account_info(self):
  686. return {"balances": [{"asset_code": "USD", "available": 41.29}, {"asset_code": "XRP", "available": 9.98954}]}
  687. def cancel_all_orders(self):
  688. self.cancelled_all += 1
  689. return {"ok": True}
  690. def suggest_order_amount(self, **kwargs):
  691. return 10.0
  692. def place_order(self, **kwargs):
  693. self.placed_orders.append(kwargs)
  694. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  695. ctx = FakeContext()
  696. strategy = GridStrategy(ctx, {"grid_levels": 2, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  697. strategy.state["center_price"] = 1.3907
  698. strategy.state["seeded"] = True
  699. strategy.state["base_available"] = 9.98954
  700. strategy.state["counter_available"] = 41.29
  701. strategy.state["orders"] = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": "o1"} for _ in range(5)]
  702. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  703. def fake_sync_open_orders_state():
  704. live = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": f"o{i}"} for i in range(5)]
  705. strategy.state["orders"] = live
  706. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  707. strategy.state["open_order_count"] = 5
  708. return live
  709. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  710. monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: True)
  711. monkeypatch.setattr(strategy, "_price", lambda: 1.3915)
  712. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  713. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  714. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  715. result = strategy.on_tick({})
  716. assert result["action"] in {"seed", "reseed"}
  717. assert ctx.cancelled_all == 1
  718. assert len(ctx.placed_orders) > 0
  719. def test_grid_recenters_exactly_on_live_price():
  720. class FakeContext:
  721. base_currency = "XRP"
  722. counter_currency = "USD"
  723. market_symbol = "xrpusd"
  724. minimum_order_value = 10.0
  725. mode = "active"
  726. def cancel_all_orders(self):
  727. return {"ok": True}
  728. def cancel_all_orders_confirmed(self):
  729. return {"conclusive": True, "error": None, "cleanup_status": "cleanup_confirmed", "cancelled_order_ids": []}
  730. def get_fee_rates(self, market):
  731. return {"maker": 0.0, "taker": 0.0}
  732. def suggest_order_amount(self, **kwargs):
  733. return 0.1
  734. def place_order(self, **kwargs):
  735. return {"status": "ok", "id": "oid-1"}
  736. strategy = GridStrategy(FakeContext(), {})
  737. strategy.state["center_price"] = 100.0
  738. strategy._recenter_and_rebuild_from_price(160.0, "test recenter")
  739. assert strategy.state["center_price"] == 160.0
  740. def test_grid_recenter_preserves_tracked_orders_when_cancel_is_inconclusive():
  741. class FakeContext:
  742. base_currency = "XRP"
  743. counter_currency = "USD"
  744. market_symbol = "xrpusd"
  745. minimum_order_value = 10.0
  746. mode = "active"
  747. def __init__(self):
  748. self.placed_orders = []
  749. def cancel_all_orders_confirmed(self):
  750. return {
  751. "conclusive": False,
  752. "error": "auth breaker active",
  753. "cleanup_status": "cleanup_partial",
  754. "cancelled_order_ids": ["o1"],
  755. }
  756. def get_fee_rates(self, market):
  757. return {"maker": 0.0, "taker": 0.0}
  758. def suggest_order_amount(self, **kwargs):
  759. return 0.1
  760. def place_order(self, **kwargs):
  761. self.placed_orders.append(kwargs)
  762. return {"status": "ok", "id": "oid-1"}
  763. ctx = FakeContext()
  764. strategy = GridStrategy(ctx, {})
  765. strategy.state["center_price"] = 100.0
  766. strategy.state["orders"] = [{"id": "o1"}, {"id": "o2"}]
  767. strategy.state["order_ids"] = ["o1", "o2"]
  768. strategy.state["open_order_count"] = 2
  769. strategy._recenter_and_rebuild_from_price(160.0, "test recenter")
  770. assert strategy.state["center_price"] == 100.0
  771. assert strategy.state["orders"] == [{"id": "o2"}]
  772. assert strategy.state["order_ids"] == ["o2"]
  773. assert strategy.state["open_order_count"] == 1
  774. assert strategy.state["cleanup_status"] == "cleanup_partial"
  775. assert strategy.state["last_action"] == "test recenter cleanup pending"
  776. assert ctx.placed_orders == []
  777. def test_grid_stop_cancels_all_open_orders():
  778. class FakeContext:
  779. base_currency = "XRP"
  780. counter_currency = "USD"
  781. market_symbol = "xrpusd"
  782. minimum_order_value = 10.0
  783. mode = "active"
  784. def __init__(self):
  785. self.cancelled = False
  786. def cancel_all_orders(self):
  787. self.cancelled = True
  788. return {"ok": True}
  789. def cancel_all_orders_confirmed(self):
  790. self.cancelled = True
  791. return {"conclusive": True, "error": None, "cleanup_status": "cleanup_confirmed", "cancelled_order_ids": ["o1"]}
  792. def get_fee_rates(self, market):
  793. return {"maker": 0.0, "taker": 0.0}
  794. strategy = GridStrategy(FakeContext(), {})
  795. strategy.state["orders"] = [{"id": "o1"}]
  796. strategy.state["order_ids"] = ["o1"]
  797. strategy.state["open_order_count"] = 1
  798. strategy.on_stop()
  799. assert strategy.context.cancelled is True
  800. assert strategy.state["open_order_count"] == 0
  801. assert strategy.state["cleanup_status"] == "cleanup_confirmed"
  802. assert strategy.state["last_action"] == "stopped"
  803. def test_grid_stop_preserves_tracked_orders_when_cancel_is_inconclusive():
  804. class FakeContext:
  805. base_currency = "XRP"
  806. counter_currency = "USD"
  807. market_symbol = "xrpusd"
  808. minimum_order_value = 10.0
  809. mode = "active"
  810. def cancel_all_orders_confirmed(self):
  811. return {
  812. "conclusive": False,
  813. "error": "auth breaker active",
  814. "cleanup_status": "cleanup_failed",
  815. "cancelled_order_ids": [],
  816. }
  817. def get_fee_rates(self, market):
  818. return {"maker": 0.0, "taker": 0.0}
  819. strategy = GridStrategy(FakeContext(), {})
  820. strategy.state["orders"] = [{"id": "o1"}]
  821. strategy.state["order_ids"] = ["o1"]
  822. strategy.state["open_order_count"] = 1
  823. strategy.on_stop()
  824. assert strategy.state["orders"] == [{"id": "o1"}]
  825. assert strategy.state["order_ids"] == ["o1"]
  826. assert strategy.state["open_order_count"] == 1
  827. assert strategy.state["cleanup_status"] == "cleanup_failed"
  828. assert strategy.state["last_action"] == "stop cleanup pending"
  829. assert strategy.state["last_error"] == "auth breaker active"
  830. def test_grid_observe_mode_preserves_tracked_orders_when_cancel_is_inconclusive(monkeypatch):
  831. class FakeContext:
  832. base_currency = "XRP"
  833. counter_currency = "USD"
  834. market_symbol = "xrpusd"
  835. minimum_order_value = 10.0
  836. mode = "observe"
  837. def cancel_all_orders_confirmed(self):
  838. return {
  839. "conclusive": False,
  840. "error": "auth breaker active",
  841. "cleanup_status": "cleanup_failed",
  842. "cancelled_order_ids": [],
  843. }
  844. def get_fee_rates(self, market):
  845. return {"maker": 0.0, "taker": 0.0}
  846. def get_account_info(self):
  847. return {"balances": [{"asset_code": "USD", "available": 50.0}, {"asset_code": "XRP", "available": 5.0}]}
  848. strategy = GridStrategy(FakeContext(), {})
  849. strategy.state["center_price"] = 1.42
  850. strategy.state["seeded"] = True
  851. strategy.state["orders"] = [{"id": "o1", "side": "buy", "price": 1.4, "amount": 10.0}]
  852. strategy.state["order_ids"] = ["o1"]
  853. strategy.state["open_order_count"] = 1
  854. monkeypatch.setattr(strategy, "_price", lambda: 1.42)
  855. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  856. monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: True)
  857. monkeypatch.setattr(strategy, "_sync_open_orders_state", lambda: [{"id": "o1", "side": "buy", "price": 1.4, "amount": 10.0}])
  858. result = strategy.on_tick({})
  859. assert result["action"] == "observe"
  860. assert result["cleanup_pending"] is True
  861. assert strategy.state["orders"] == [{"id": "o1", "side": "buy", "price": 1.4, "amount": 10.0}]
  862. assert strategy.state["order_ids"] == ["o1"]
  863. assert strategy.state["open_order_count"] == 1
  864. assert strategy.state["cleanup_status"] == "cleanup_failed"
  865. assert strategy.state["last_action"] == "observe cleanup pending"
  866. assert strategy.state["last_error"] == "auth breaker active"
  867. def test_base_strategy_report_uses_context_snapshot():
  868. class FakeContext:
  869. id = "s-1"
  870. account_id = "acct-1"
  871. market_symbol = "xrpusd"
  872. base_currency = "XRP"
  873. counter_currency = "USD"
  874. mode = "active"
  875. def get_strategy_snapshot(self):
  876. return {
  877. "identity": {"strategy_id": "s-1", "strategy_name": "Demo", "account_id": "acct-1", "market": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"},
  878. "control": {"enabled_state": "on", "mode": "active"},
  879. "position": {"balances": [{"asset_code": "XRP", "available": 1.0}]},
  880. "orders": {"open_orders": [{"id": "o1"}]},
  881. "execution": {"execution_quality": "good"},
  882. }
  883. class DemoStrategy(BaseStrategy):
  884. LABEL = "Demo"
  885. report = DemoStrategy(FakeContext(), {}).report()
  886. assert report["identity"]["strategy_id"] == "s-1"
  887. assert report["control"]["mode"] == "active"
  888. assert report["position"]["open_orders"][0]["id"] == "o1"
  889. def test_dumb_trader_uses_policy_and_reports_fit():
  890. class FakeContext:
  891. id = "s-2"
  892. account_id = "acct-2"
  893. client_id = "cid-2"
  894. mode = "active"
  895. market_symbol = "xrpusd"
  896. base_currency = "XRP"
  897. counter_currency = "USD"
  898. def get_price(self, symbol):
  899. return {"price": 1.2}
  900. def place_order(self, **kwargs):
  901. return {"ok": True, "order": kwargs}
  902. def get_strategy_snapshot(self):
  903. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  904. strat = DumbStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 1.5})
  905. strat.apply_policy()
  906. report = strat.report()
  907. assert report["fit"]["risk_profile"] == "neutral"
  908. assert strat.state["policy_derived"]["order_notional_quote"] > 0
  909. def test_dumb_trader_buys_on_configured_side_without_regime_input():
  910. class FakeContext:
  911. id = "s-bull"
  912. account_id = "acct-1"
  913. client_id = "cid-1"
  914. mode = "active"
  915. market_symbol = "xrpusd"
  916. base_currency = "XRP"
  917. counter_currency = "USD"
  918. def __init__(self):
  919. self.orders = []
  920. def get_price(self, symbol):
  921. return {"price": 1.2}
  922. def place_order(self, **kwargs):
  923. self.orders.append(kwargs)
  924. return {"ok": True, "order": kwargs}
  925. def get_account_info(self):
  926. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  927. minimum_order_value = 10.0
  928. def suggest_order_amount(self, **kwargs):
  929. return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
  930. def get_strategy_snapshot(self):
  931. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  932. ctx = FakeContext()
  933. strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 20.0})
  934. result = strat.on_tick({})
  935. assert result["action"] == "buy"
  936. assert ctx.orders[-1]["side"] == "buy"
  937. assert ctx.orders[-1]["amount"] == 20.0 / 1.2
  938. assert strat.state["last_action"] == "buy_dumb"
  939. def test_dumb_trader_sells_on_configured_side_without_regime_input():
  940. class FakeContext:
  941. id = "s-bear"
  942. account_id = "acct-1"
  943. client_id = "cid-1"
  944. mode = "active"
  945. market_symbol = "xrpusd"
  946. base_currency = "XRP"
  947. counter_currency = "USD"
  948. def __init__(self):
  949. self.orders = []
  950. def get_price(self, symbol):
  951. return {"price": 1.2}
  952. def place_order(self, **kwargs):
  953. self.orders.append(kwargs)
  954. return {"ok": True, "order": kwargs}
  955. def get_account_info(self):
  956. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 10}]}
  957. minimum_order_value = 10.0
  958. def suggest_order_amount(self, **kwargs):
  959. return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
  960. def get_strategy_snapshot(self):
  961. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  962. ctx = FakeContext()
  963. strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 20.0})
  964. result = strat.on_tick({})
  965. assert result["action"] == "sell"
  966. assert ctx.orders[-1]["side"] == "sell"
  967. assert ctx.orders[-1]["amount"] == 20.0 / 1.2
  968. assert strat.state["last_action"] == "sell_dumb"
  969. def test_dumb_trader_buy_only_ignores_bear_regime():
  970. class FakeContext:
  971. id = "s-buy-only"
  972. account_id = "acct-1"
  973. client_id = "cid-1"
  974. mode = "active"
  975. market_symbol = "xrpusd"
  976. base_currency = "XRP"
  977. counter_currency = "USD"
  978. def __init__(self):
  979. self.orders = []
  980. def get_price(self, symbol):
  981. return {"price": 1.2}
  982. def get_regime(self, symbol, timeframe="1h"):
  983. return {
  984. "trend": {"state": "bear", "ema_fast": 1.17, "ema_slow": 1.2},
  985. "momentum": {"state": "bear", "rsi": 36, "macd_histogram": -0.002},
  986. }
  987. def place_order(self, **kwargs):
  988. self.orders.append(kwargs)
  989. return {"ok": True, "order": kwargs}
  990. def get_account_info(self):
  991. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 10}]}
  992. minimum_order_value = 10.0
  993. def suggest_order_amount(self, **kwargs):
  994. return 10.0
  995. def get_strategy_snapshot(self):
  996. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  997. ctx = FakeContext()
  998. strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 20.0})
  999. result = strat.on_tick({})
  1000. assert result["action"] == "buy"
  1001. assert ctx.orders[-1]["side"] == "buy"
  1002. assert strat.state["last_action"] == "buy_dumb"
  1003. def test_dumb_trader_sell_only_ignores_bull_regime():
  1004. class FakeContext:
  1005. id = "s-sell-only"
  1006. account_id = "acct-1"
  1007. client_id = "cid-1"
  1008. mode = "active"
  1009. market_symbol = "xrpusd"
  1010. base_currency = "XRP"
  1011. counter_currency = "USD"
  1012. def __init__(self):
  1013. self.orders = []
  1014. def get_price(self, symbol):
  1015. return {"price": 1.2}
  1016. def get_regime(self, symbol, timeframe="1h"):
  1017. return {
  1018. "trend": {"state": "bull", "ema_fast": 1.21, "ema_slow": 1.18},
  1019. "momentum": {"state": "bull", "rsi": 64, "macd_histogram": 0.002},
  1020. }
  1021. def place_order(self, **kwargs):
  1022. self.orders.append(kwargs)
  1023. return {"ok": True, "order": kwargs}
  1024. def get_account_info(self):
  1025. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  1026. minimum_order_value = 10.0
  1027. def suggest_order_amount(self, **kwargs):
  1028. return 10.0
  1029. def get_strategy_snapshot(self):
  1030. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1031. ctx = FakeContext()
  1032. strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 20.0})
  1033. result = strat.on_tick({})
  1034. assert result["action"] == "sell"
  1035. assert ctx.orders[-1]["side"] == "sell"
  1036. assert strat.state["last_action"] == "sell_dumb"
  1037. def test_dumb_trader_policy_does_not_override_explicit_order_notional_quote():
  1038. class FakeContext:
  1039. id = "s-explicit"
  1040. account_id = "acct-1"
  1041. client_id = "cid-1"
  1042. mode = "active"
  1043. market_symbol = "xrpusd"
  1044. base_currency = "XRP"
  1045. counter_currency = "USD"
  1046. def get_price(self, symbol):
  1047. return {"price": 1.2}
  1048. def get_strategy_snapshot(self):
  1049. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1050. strat = DumbStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 10.5})
  1051. strat.apply_policy()
  1052. assert strat.config["order_notional_quote"] == 10.5
  1053. assert strat.state["policy_derived"]["order_notional_quote"] == 10.5
  1054. def test_dumb_trader_passes_live_fee_rate_into_sizing_helper():
  1055. class FakeContext:
  1056. id = "s-fee"
  1057. account_id = "acct-1"
  1058. client_id = "cid-1"
  1059. mode = "active"
  1060. market_symbol = "xrpusd"
  1061. base_currency = "XRP"
  1062. counter_currency = "USD"
  1063. minimum_order_value = 10.0
  1064. def __init__(self):
  1065. self.fee_calls = []
  1066. self.suggest_calls = []
  1067. def get_price(self, symbol):
  1068. return {"price": 1.2}
  1069. def get_fee_rates(self, market_symbol=None):
  1070. self.fee_calls.append(market_symbol)
  1071. return {"maker": 0.0025, "taker": 0.004}
  1072. def suggest_order_amount(self, **kwargs):
  1073. self.suggest_calls.append(kwargs)
  1074. return 8.0
  1075. def place_order(self, **kwargs):
  1076. return {"ok": True, "order": kwargs}
  1077. def get_account_info(self):
  1078. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  1079. def get_strategy_snapshot(self):
  1080. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1081. ctx = FakeContext()
  1082. strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 10.5, "dust_collect": True})
  1083. strat.on_tick({})
  1084. assert ctx.fee_calls == ["xrpusd"]
  1085. assert ctx.suggest_calls[-1]["fee_rate"] == 0.0025
  1086. assert ctx.suggest_calls[-1]["dust_collect"] is True
  1087. def test_grid_sizing_helper_receives_quote_controls_and_dust_collect():
  1088. class FakeContext:
  1089. base_currency = "XRP"
  1090. counter_currency = "USD"
  1091. market_symbol = "xrpusd"
  1092. minimum_order_value = 10.0
  1093. mode = "active"
  1094. def __init__(self):
  1095. self.suggest_calls = []
  1096. def get_fee_rates(self, market_symbol=None):
  1097. return {"maker": 0.001, "taker": 0.004}
  1098. def suggest_order_amount(self, **kwargs):
  1099. self.suggest_calls.append(kwargs)
  1100. return 7.0
  1101. ctx = FakeContext()
  1102. strategy = GridStrategy(
  1103. ctx,
  1104. {
  1105. "grid_levels": 3,
  1106. "order_notional_quote": 11.0,
  1107. "max_order_notional_quote": 12.0,
  1108. "dust_collect": True,
  1109. },
  1110. )
  1111. amount = strategy._suggest_amount("buy", 1.5, 3, 10.0)
  1112. assert amount == 7.0
  1113. assert ctx.suggest_calls[-1]["fee_rate"] == 0.004
  1114. assert ctx.suggest_calls[-1]["quote_notional"] == 11.0
  1115. assert ctx.suggest_calls[-1]["max_notional_per_order"] == 12.0
  1116. assert ctx.suggest_calls[-1]["dust_collect"] is True
  1117. assert ctx.suggest_calls[-1]["levels"] == 3
  1118. def test_dumb_trader_buy_uses_requested_notional_even_with_balance_target_configured():
  1119. class FakeContext:
  1120. id = "s-buy-clamp"
  1121. account_id = "acct-1"
  1122. client_id = "cid-1"
  1123. mode = "active"
  1124. market_symbol = "xrpusd"
  1125. base_currency = "XRP"
  1126. counter_currency = "USD"
  1127. minimum_order_value = 0.5
  1128. def __init__(self):
  1129. self.orders = []
  1130. def get_price(self, symbol):
  1131. return {"price": 1.0}
  1132. def get_fee_rates(self, market_symbol=None):
  1133. return {"maker": 0.0, "taker": 0.0}
  1134. def suggest_order_amount(self, **kwargs):
  1135. return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
  1136. def place_order(self, **kwargs):
  1137. self.orders.append(kwargs)
  1138. return {"ok": True, "order": kwargs}
  1139. def get_account_info(self):
  1140. return {"balances": [{"asset_code": "USD", "available": 6.0}, {"asset_code": "XRP", "available": 4.0}]}
  1141. def get_strategy_snapshot(self):
  1142. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1143. ctx = FakeContext()
  1144. strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 3.0, "balance_target": 0.5})
  1145. result = strat.on_tick({})
  1146. assert result["action"] == "buy"
  1147. assert ctx.orders[-1]["amount"] == 3.0
  1148. def test_dumb_trader_sell_uses_requested_notional_even_with_balance_target_configured():
  1149. class FakeContext:
  1150. id = "s-sell-clamp"
  1151. account_id = "acct-1"
  1152. client_id = "cid-1"
  1153. mode = "active"
  1154. market_symbol = "xrpusd"
  1155. base_currency = "XRP"
  1156. counter_currency = "USD"
  1157. minimum_order_value = 0.5
  1158. def __init__(self):
  1159. self.orders = []
  1160. def get_price(self, symbol):
  1161. return {"price": 1.0}
  1162. def get_fee_rates(self, market_symbol=None):
  1163. return {"maker": 0.0, "taker": 0.0}
  1164. def suggest_order_amount(self, **kwargs):
  1165. return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
  1166. def place_order(self, **kwargs):
  1167. self.orders.append(kwargs)
  1168. return {"ok": True, "order": kwargs}
  1169. def get_account_info(self):
  1170. return {"balances": [{"asset_code": "USD", "available": 3.0}, {"asset_code": "XRP", "available": 7.0}]}
  1171. def get_strategy_snapshot(self):
  1172. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1173. ctx = FakeContext()
  1174. strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 5.0, "balance_target": 0.5})
  1175. result = strat.on_tick({})
  1176. assert result["action"] == "sell"
  1177. assert ctx.orders[-1]["amount"] == 5.0
  1178. def test_dumb_trader_sell_holds_sub_minimum_order():
  1179. class FakeContext:
  1180. id = "s-sell-min"
  1181. account_id = "acct-1"
  1182. client_id = "cid-1"
  1183. mode = "active"
  1184. market_symbol = "solusd"
  1185. base_currency = "SOL"
  1186. counter_currency = "USD"
  1187. minimum_order_value = 1.0
  1188. def __init__(self):
  1189. self.orders = []
  1190. def get_price(self, symbol):
  1191. return {"price": 86.20062}
  1192. def get_fee_rates(self, market_symbol=None):
  1193. return {"maker": 0.0, "taker": 0.0}
  1194. def suggest_order_amount(self, **kwargs):
  1195. return 0.001
  1196. def place_order(self, **kwargs):
  1197. self.orders.append(kwargs)
  1198. return {"ok": True, "order": kwargs}
  1199. def get_account_info(self):
  1200. return {"balances": [{"asset_code": "USD", "available": 1000.0}, {"asset_code": "SOL", "available": 0.00447}]}
  1201. def get_strategy_snapshot(self):
  1202. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1203. ctx = FakeContext()
  1204. strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 5.0, "balance_target": 1.0})
  1205. result = strat.on_tick({})
  1206. assert result["action"] == "hold"
  1207. assert result["reason"] == "no usable size"
  1208. assert ctx.orders == []
  1209. def test_dumb_trader_holds_when_live_sizing_reports_no_affordable_sell():
  1210. class FakeContext:
  1211. id = "s-sell-no-funds"
  1212. account_id = "acct-1"
  1213. client_id = "cid-1"
  1214. mode = "active"
  1215. market_symbol = "solusd"
  1216. base_currency = "SOL"
  1217. counter_currency = "USD"
  1218. minimum_order_value = 0.5
  1219. def __init__(self):
  1220. self.orders = []
  1221. def get_price(self, symbol):
  1222. return {"price": 86.69}
  1223. def get_fee_rates(self, market_symbol=None):
  1224. return {"maker": 0.0, "taker": 0.0}
  1225. def suggest_order_amount(self, **kwargs):
  1226. return 0.0
  1227. def place_order(self, **kwargs):
  1228. self.orders.append(kwargs)
  1229. return {"ok": True, "order": kwargs}
  1230. def get_account_info(self):
  1231. return {"balances": [{"asset_code": "USD", "available": 1000.0}, {"asset_code": "SOL", "available": 0.00447}]}
  1232. def get_strategy_snapshot(self):
  1233. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1234. ctx = FakeContext()
  1235. strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 11.0, "dust_collect": True})
  1236. result = strat.on_tick({})
  1237. assert result["action"] == "hold"
  1238. assert result["reason"] == "no usable size"
  1239. assert ctx.orders == []
  1240. def test_dumb_trader_holds_when_balance_refresh_fails_after_restart():
  1241. class FakeContext:
  1242. id = "s-restart-hold"
  1243. account_id = "acct-1"
  1244. client_id = "cid-1"
  1245. mode = "active"
  1246. market_symbol = "solusd"
  1247. base_currency = "SOL"
  1248. counter_currency = "USD"
  1249. minimum_order_value = 1.0
  1250. def __init__(self):
  1251. self.orders = []
  1252. def get_price(self, symbol):
  1253. return {"price": 86.51}
  1254. def get_fee_rates(self, market_symbol=None):
  1255. return {"maker": 0.0, "taker": 0.0}
  1256. def get_account_info(self):
  1257. raise RuntimeError("Bitstamp auth breaker active, retry later")
  1258. def suggest_order_amount(self, **kwargs):
  1259. raise AssertionError("should not size an order after balance refresh failure")
  1260. def place_order(self, **kwargs):
  1261. self.orders.append(kwargs)
  1262. return {"ok": True, "order": kwargs}
  1263. def get_strategy_snapshot(self):
  1264. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1265. ctx = FakeContext()
  1266. strat = DumbStrategy(
  1267. ctx,
  1268. {
  1269. "trade_side": "sell",
  1270. "order_notional_quote": 5.0,
  1271. "dust_collect": True,
  1272. },
  1273. )
  1274. strat.state["base_available"] = 0.127153
  1275. strat.state["counter_available"] = 0.0
  1276. result = strat.on_tick({})
  1277. assert result["action"] == "hold"
  1278. assert result["reason"] == "balance refresh unavailable"
  1279. assert strat.state["balance_snapshot_ok"] is False
  1280. assert strat.state["base_available"] == 0.0
  1281. assert ctx.orders == []
  1282. def test_dumb_trader_holds_when_trade_side_is_symmetrical():
  1283. class FakeContext:
  1284. id = "s-target-hold"
  1285. account_id = "acct-1"
  1286. client_id = "cid-1"
  1287. mode = "active"
  1288. market_symbol = "xrpusd"
  1289. base_currency = "XRP"
  1290. counter_currency = "USD"
  1291. minimum_order_value = 0.5
  1292. def get_price(self, symbol):
  1293. return {"price": 1.0}
  1294. def get_fee_rates(self, market_symbol=None):
  1295. return {"maker": 0.0, "taker": 0.0}
  1296. def suggest_order_amount(self, **kwargs):
  1297. return 10.0
  1298. def get_account_info(self):
  1299. return {"balances": [{"asset_code": "USD", "available": 5.0}, {"asset_code": "XRP", "available": 5.0}]}
  1300. def get_strategy_snapshot(self):
  1301. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1302. strat = DumbStrategy(FakeContext(), {"trade_side": "both", "order_notional_quote": 3.0, "balance_target": 0.5})
  1303. result = strat.on_tick({})
  1304. assert result["action"] == "hold"
  1305. assert result["reason"] == "trade_side must be buy or sell"
  1306. def test_cap_amount_to_balance_target_caps_sell_to_live_base():
  1307. amount = cap_amount_to_balance_target(
  1308. suggested_amount=0.127226,
  1309. side="sell",
  1310. price=86.20062,
  1311. fee_rate=0.0,
  1312. balance_target=1.0,
  1313. base_available=0.00447,
  1314. counter_available=0.0,
  1315. min_notional=0.0,
  1316. )
  1317. assert amount == 0.00447
  1318. def test_cap_amount_to_balance_target_rejects_sell_below_min_notional():
  1319. amount = cap_amount_to_balance_target(
  1320. suggested_amount=0.127226,
  1321. side="sell",
  1322. price=86.20062,
  1323. fee_rate=0.0,
  1324. balance_target=1.0,
  1325. base_available=0.00447,
  1326. counter_available=0.0,
  1327. min_notional=1.0,
  1328. )
  1329. assert amount == 0.0
  1330. def test_dumb_trader_ignores_balance_target_and_keeps_size():
  1331. class FakeContext:
  1332. id = "s-target-open"
  1333. account_id = "acct-1"
  1334. client_id = "cid-1"
  1335. mode = "active"
  1336. market_symbol = "xrpusd"
  1337. base_currency = "XRP"
  1338. counter_currency = "USD"
  1339. minimum_order_value = 0.5
  1340. def __init__(self):
  1341. self.orders = []
  1342. def get_price(self, symbol):
  1343. return {"price": 1.0}
  1344. def get_fee_rates(self, market_symbol=None):
  1345. return {"maker": 0.0, "taker": 0.0}
  1346. def suggest_order_amount(self, **kwargs):
  1347. return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
  1348. def place_order(self, **kwargs):
  1349. self.orders.append(kwargs)
  1350. return {"ok": True, "order": kwargs}
  1351. def get_account_info(self):
  1352. return {"balances": [{"asset_code": "USD", "available": 6.0}, {"asset_code": "XRP", "available": 4.0}]}
  1353. def get_strategy_snapshot(self):
  1354. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  1355. ctx = FakeContext()
  1356. strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 3.0, "balance_target": 0.5})
  1357. result = strat.on_tick({})
  1358. assert result["action"] == "buy"
  1359. assert ctx.orders[-1]["amount"] == 3.0
  1360. def test_exposure_protector_buy_sizing_respects_fee_when_min_order_quote_is_unaffordable():
  1361. class FakeContext:
  1362. account_id = "acct-1"
  1363. client_id = "cid-1"
  1364. mode = "active"
  1365. market_symbol = "xrpusd"
  1366. base_currency = "XRP"
  1367. counter_currency = "USD"
  1368. def get_fee_rates(self, market_symbol=None):
  1369. return {"maker": 0.1, "taker": 0.2}
  1370. strategy = ExposureStrategy(
  1371. FakeContext(),
  1372. {
  1373. "rebalance_target_ratio": 0.9,
  1374. "rebalance_step_ratio": 1.0,
  1375. "balance_tolerance": 0.0,
  1376. "min_order_notional_quote": 10.0,
  1377. },
  1378. )
  1379. strategy.state["base_available"] = 0.0
  1380. strategy.state["counter_available"] = 10.0
  1381. amount = strategy._suggest_amount("buy", 1.0)
  1382. assert amount == 0.0