test_strategies.py 58 KB

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