test_strategies.py 66 KB

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