test_strategies.py 44 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317
  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_stop_loss_strategy_loads_with_aligned_regime_config(tmp_path):
  73. original_db = strategy_store.DB_PATH
  74. original_dir = strategy_registry.STRATEGIES_DIR
  75. try:
  76. strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
  77. strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
  78. strategy_registry.STRATEGIES_DIR.mkdir()
  79. (strategy_registry.STRATEGIES_DIR / "grid_trader.py").write_text((Path(__file__).resolve().parents[1] / "strategies" / "grid_trader.py").read_text())
  80. (strategy_registry.STRATEGIES_DIR / "exposure_protector.py").write_text((Path(__file__).resolve().parents[1] / "strategies" / "exposure_protector.py").read_text())
  81. grid_defaults = strategy_registry.get_strategy_default_config("grid_trader")
  82. stop_defaults = strategy_registry.get_strategy_default_config("exposure_protector")
  83. assert grid_defaults["trade_sides"] == "both"
  84. assert grid_defaults["grid_step_pct"] == 0.012
  85. assert stop_defaults["trail_distance_pct"] == 0.03
  86. assert stop_defaults["rebalance_target_ratio"] == 0.5
  87. assert stop_defaults["min_rebalance_seconds"] == 180
  88. assert stop_defaults["min_price_move_pct"] == 0.005
  89. finally:
  90. strategy_store.DB_PATH = original_db
  91. strategy_registry.STRATEGIES_DIR = original_dir
  92. def test_grid_supervision_reports_factual_capacity_not_handoff_commands():
  93. class FakeContext:
  94. account_id = "acct-1"
  95. market_symbol = "xrpusd"
  96. base_currency = "XRP"
  97. counter_currency = "USD"
  98. mode = "active"
  99. strategy = GridStrategy(FakeContext(), {})
  100. strategy.state.update({
  101. "last_price": 1.45,
  102. "base_available": 50.0,
  103. "counter_available": 38.7,
  104. "regimes": {"1h": {"trend": {"state": "bull"}}},
  105. })
  106. supervision = strategy._supervision()
  107. assert supervision["inventory_pressure"] == "base_heavy"
  108. assert supervision["capacity_available"] is False
  109. assert supervision["side_capacity"] == {"buy": True, "sell": True}
  110. strategy.state.update({
  111. "base_available": 88.0,
  112. "counter_available": 4.0,
  113. })
  114. supervision = strategy._supervision()
  115. assert supervision["inventory_pressure"] == "base_side_depleted"
  116. assert supervision["side_capacity"] == {"buy": True, "sell": False}
  117. def test_grid_supervision_exposes_adverse_side_open_orders():
  118. class FakeContext:
  119. account_id = "acct-1"
  120. market_symbol = "xrpusd"
  121. base_currency = "XRP"
  122. counter_currency = "USD"
  123. mode = "active"
  124. strategy = GridStrategy(FakeContext(), {})
  125. strategy.state.update({
  126. "last_price": 1.60,
  127. "center_price": 1.45,
  128. "orders": [
  129. {"side": "sell", "price": "1.62", "amount": "10", "status": "open"},
  130. {"side": "sell", "price": "1.66", "amount": "5", "status": "open"},
  131. {"side": "buy", "price": "1.38", "amount": "7", "status": "open"},
  132. ],
  133. })
  134. supervision = strategy._supervision()
  135. assert supervision["market_bias"] == "bullish"
  136. assert supervision["adverse_side"] == "sell"
  137. assert supervision["adverse_side_open_order_count"] == 2
  138. assert supervision["adverse_side_open_order_notional_quote"] > 0
  139. assert "sell ladder exposed" in " ".join(supervision["concerns"])
  140. def test_dumb_trader_and_protector_supervision_reports_facts_only():
  141. class FakeContext:
  142. account_id = "acct-1"
  143. market_symbol = "xrpusd"
  144. base_currency = "XRP"
  145. counter_currency = "USD"
  146. mode = "active"
  147. trend = DumbStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 1.0})
  148. trend.state.update({"last_price": 1.45, "base_available": 20.0, "counter_available": 20.0, "last_order_at": 0.0})
  149. trend_supervision = trend._supervision()
  150. assert trend_supervision["trade_side"] == "buy"
  151. assert trend_supervision["capacity_available"] is True
  152. assert trend_supervision["entry_offset_pct"] == 0.003
  153. assert trend_supervision["chasing_risk"] in {"low", "moderate", "elevated"}
  154. assert "switch_readiness" not in trend_supervision
  155. assert "desired_companion" not in trend_supervision
  156. protector = ExposureStrategy(FakeContext(), {})
  157. protector.state.update({"last_price": 1.45, "base_available": 40.0, "counter_available": 10.0})
  158. protector_supervision = protector._supervision()
  159. assert protector_supervision["rebalance_needed"] is True
  160. assert protector_supervision["repair_progress"] <= 1.0
  161. assert "switch_readiness" not in protector_supervision
  162. assert "desired_companion" not in protector_supervision
  163. def test_exposure_protector_holds_inside_hysteresis_band(monkeypatch):
  164. class FakeContext:
  165. account_id = "acct-1"
  166. market_symbol = "xrpusd"
  167. base_currency = "XRP"
  168. counter_currency = "USD"
  169. mode = "active"
  170. def __init__(self):
  171. self.placed_orders = []
  172. def get_account_info(self):
  173. return {"balances": [{"asset_code": "XRP", "available": 9.2}, {"asset_code": "USD", "available": 10.0}]}
  174. def get_price(self, market):
  175. return {"price": 1.0}
  176. def get_fee_rates(self, market):
  177. return {"maker": 0.0, "taker": 0.004}
  178. def place_order(self, **kwargs):
  179. self.placed_orders.append(kwargs)
  180. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  181. ctx = FakeContext()
  182. 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})
  183. strategy.state["last_rebalance_side"] = "sell"
  184. strategy.state["last_order_at"] = 0
  185. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  186. result = strategy.on_tick({})
  187. assert result["action"] == "hold"
  188. assert result["reason"] == "within rebalance hysteresis"
  189. assert ctx.placed_orders == []
  190. def test_grid_apply_policy_keeps_explicit_grid_levels():
  191. class FakeContext:
  192. account_id = "acct-1"
  193. market_symbol = "xrpusd"
  194. base_currency = "XRP"
  195. counter_currency = "USD"
  196. mode = "active"
  197. strategy = GridStrategy(FakeContext(), {"grid_levels": 5, "policy": {"risk_posture": "normal"}})
  198. strategy.apply_policy()
  199. assert strategy.config["grid_levels"] == 5
  200. assert strategy.state["policy_derived"]["grid_levels"] == 5
  201. def test_grid_seed_keeps_other_side_when_one_side_fails(monkeypatch):
  202. class FakeContext:
  203. base_currency = "XRP"
  204. counter_currency = "USD"
  205. market_symbol = "xrpusd"
  206. minimum_order_value = 10.0
  207. mode = "active"
  208. def __init__(self):
  209. self.attempts = []
  210. self.buy_attempts = 0
  211. self.sell_attempts = 0
  212. def get_fee_rates(self, market):
  213. return {"maker": 0.0, "taker": 0.0}
  214. def suggest_order_amount(self, **kwargs):
  215. return 10.0
  216. def place_order(self, **kwargs):
  217. self.attempts.append(kwargs)
  218. if kwargs["side"] == "buy":
  219. self.buy_attempts += 1
  220. if self.buy_attempts == 3:
  221. raise RuntimeError("insufficient USD")
  222. elif kwargs["side"] == "sell":
  223. self.sell_attempts += 1
  224. return {"status": "ok", "id": f"{kwargs['side']}-{len(self.attempts)}"}
  225. ctx = FakeContext()
  226. strategy = GridStrategy(ctx, {"grid_levels": 5, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.0})
  227. strategy.state["center_price"] = 100.0
  228. monkeypatch.setattr(strategy, "_supported_levels", lambda side, center, min_notional: 5)
  229. monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: None)
  230. strategy._place_grid(100.0)
  231. orders = strategy.state["orders"]
  232. assert ctx.buy_attempts == 5
  233. assert ctx.sell_attempts == 5
  234. assert len([o for o in orders if o["side"] == "buy"]) == 4
  235. assert len([o for o in orders if o["side"] == "sell"]) == 5
  236. assert any("partial success" in line for line in (strategy.state.get("debug_log") or [])) or strategy.state.get("last_error") == "insufficient USD"
  237. def test_grid_skips_rebuild_when_balance_refresh_fails(monkeypatch):
  238. class FakeContext:
  239. base_currency = "XRP"
  240. counter_currency = "USD"
  241. market_symbol = "xrpusd"
  242. minimum_order_value = 10.0
  243. mode = "active"
  244. def __init__(self):
  245. self.cancelled_all = 0
  246. self.placed_orders = []
  247. def get_fee_rates(self, market):
  248. return {"maker": 0.0, "taker": 0.004}
  249. def get_account_info(self):
  250. raise RuntimeError("Bitstamp auth breaker active, retry later")
  251. def cancel_all_orders(self):
  252. self.cancelled_all += 1
  253. return {"ok": True}
  254. def suggest_order_amount(self, **kwargs):
  255. return 10.0
  256. def place_order(self, **kwargs):
  257. self.placed_orders.append(kwargs)
  258. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  259. ctx = FakeContext()
  260. strategy = GridStrategy(ctx, {"grid_levels": 5, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  261. strategy.state["center_price"] = 1.4397
  262. strategy.state["seeded"] = True
  263. strategy.state["orders"] = [{"side": "buy", "price": 1.43, "amount": 10.0, "id": "o1"}]
  264. strategy.state["order_ids"] = ["o1"]
  265. monkeypatch.setattr(strategy, "_sync_open_orders_state", lambda: [{"side": "buy", "price": 1.43, "amount": 10.0, "id": "o1"}])
  266. monkeypatch.setattr(strategy, "_price", lambda: 1.4397)
  267. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  268. result = strategy.on_tick({})
  269. assert result["action"] == "hold"
  270. assert result["reason"] == "balance refresh unavailable"
  271. assert ctx.cancelled_all == 0
  272. assert ctx.placed_orders == []
  273. def test_grid_skips_shape_rebuild_when_balance_reads_turn_inconclusive(monkeypatch):
  274. class FakeContext:
  275. base_currency = "XRP"
  276. counter_currency = "USD"
  277. market_symbol = "xrpusd"
  278. minimum_order_value = 10.0
  279. mode = "active"
  280. def __init__(self):
  281. self.cancelled_all = 0
  282. self.placed_orders = []
  283. self.calls = 0
  284. def get_fee_rates(self, market):
  285. return {"maker": 0.0, "taker": 0.004}
  286. def get_account_info(self):
  287. self.calls += 1
  288. if self.calls == 1:
  289. return {
  290. "balances": [
  291. {"asset_code": "USD", "available": 41.29},
  292. {"asset_code": "XRP", "available": 9.98954},
  293. ]
  294. }
  295. raise RuntimeError("Bitstamp auth breaker active, retry later")
  296. def cancel_all_orders(self):
  297. self.cancelled_all += 1
  298. return {"ok": True}
  299. def suggest_order_amount(self, **kwargs):
  300. return 10.0
  301. def place_order(self, **kwargs):
  302. self.placed_orders.append(kwargs)
  303. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  304. ctx = FakeContext()
  305. strategy = GridStrategy(ctx, {"grid_levels": 2, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  306. strategy.state["center_price"] = 1.3285
  307. strategy.state["seeded"] = True
  308. strategy.state["orders"] = [
  309. {"side": "buy", "price": 1.3243993, "amount": 7.63, "id": "existing-buy"},
  310. {"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"},
  311. {"side": "sell", "price": 1.3367011, "amount": 9.0, "id": "sell-2"},
  312. ]
  313. strategy.state["order_ids"] = ["existing-buy", "sell-1", "sell-2"]
  314. def fake_sync_open_orders_state():
  315. live = [{"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"}]
  316. strategy.state["orders"] = live
  317. strategy.state["order_ids"] = ["sell-1"]
  318. strategy.state["open_order_count"] = 1
  319. return live
  320. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  321. monkeypatch.setattr(strategy, "_price", lambda: 1.3285)
  322. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  323. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  324. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  325. result = strategy.on_tick({})
  326. assert result["action"] == "hold"
  327. assert ctx.cancelled_all == 0
  328. assert ctx.placed_orders == []
  329. def test_grid_missing_order_triggers_full_rebuild(monkeypatch):
  330. class FakeContext:
  331. base_currency = "XRP"
  332. counter_currency = "USD"
  333. market_symbol = "xrpusd"
  334. minimum_order_value = 10.0
  335. mode = "active"
  336. def __init__(self):
  337. self.cancelled_all = 0
  338. self.placed_orders = []
  339. def get_fee_rates(self, market):
  340. return {"maker": 0.0, "taker": 0.004}
  341. def get_account_info(self):
  342. return {
  343. "balances": [
  344. {"asset_code": "USD", "available": 13.55},
  345. {"asset_code": "XRP", "available": 22.0103},
  346. ]
  347. }
  348. def suggest_order_amount(
  349. self,
  350. *,
  351. side,
  352. price,
  353. levels,
  354. min_notional,
  355. fee_rate,
  356. max_notional_per_order=0.0,
  357. dust_collect=False,
  358. order_size=0.0,
  359. safety=0.995,
  360. ):
  361. if side == "buy":
  362. quote_available = 13.55
  363. spendable_quote = quote_available * safety
  364. quote_cap = min(spendable_quote, max_notional_per_order) if max_notional_per_order > 0 else spendable_quote
  365. if quote_cap < min_notional * (1 + fee_rate):
  366. return 0.0
  367. return quote_cap / (price * (1 + fee_rate))
  368. return 0.0
  369. def cancel_all_orders(self):
  370. self.cancelled_all += 1
  371. return {"ok": True}
  372. def place_order(self, **kwargs):
  373. self.placed_orders.append(kwargs)
  374. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  375. ctx = FakeContext()
  376. strategy = GridStrategy(
  377. ctx,
  378. {
  379. "grid_levels": 2,
  380. "grid_step_pct": 0.0062,
  381. "grid_step_min_pct": 0.0033,
  382. "grid_step_max_pct": 0.012,
  383. "max_notional_per_order": 12,
  384. "order_call_delay_ms": 0,
  385. "trade_sides": "both",
  386. "debug_orders": True,
  387. "dust_collect": True,
  388. "enable_trend_guard": False,
  389. "fee_rate": 0.004,
  390. },
  391. )
  392. strategy.state["center_price"] = 1.3285
  393. strategy.state["seeded"] = True
  394. strategy.state["base_available"] = 22.0103
  395. strategy.state["counter_available"] = 13.55
  396. strategy.state["orders"] = [
  397. {"side": "buy", "price": 1.3243993, "amount": 7.63, "id": "existing-buy"},
  398. {"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"},
  399. {"side": "sell", "price": 1.3367011, "amount": 9.0, "id": "sell-2"},
  400. ]
  401. strategy.state["order_ids"] = ["existing-buy", "sell-1", "sell-2"]
  402. def fake_sync_open_orders_state():
  403. live = [{"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"}]
  404. strategy.state["orders"] = live
  405. strategy.state["order_ids"] = ["sell-1"]
  406. strategy.state["open_order_count"] = 1
  407. return live
  408. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  409. monkeypatch.setattr(strategy, "_price", lambda: 1.3285)
  410. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  411. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  412. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  413. result = strategy.on_tick({})
  414. assert result["action"] in {"seed", "reseed"}
  415. assert ctx.cancelled_all == 1
  416. assert len(ctx.placed_orders) > 0
  417. assert strategy.state["last_action"] == "reseeded"
  418. def test_grid_side_imbalance_triggers_full_rebuild(monkeypatch):
  419. class FakeContext:
  420. base_currency = "XRP"
  421. counter_currency = "USD"
  422. market_symbol = "xrpusd"
  423. minimum_order_value = 10.0
  424. mode = "active"
  425. def __init__(self):
  426. self.cancelled_all = 0
  427. self.placed_orders = []
  428. def get_fee_rates(self, market):
  429. return {"maker": 0.0, "taker": 0.004}
  430. def get_account_info(self):
  431. return {"balances": [{"asset_code": "USD", "available": 41.29}, {"asset_code": "XRP", "available": 9.98954}]}
  432. def cancel_all_orders(self):
  433. self.cancelled_all += 1
  434. return {"ok": True}
  435. def suggest_order_amount(self, **kwargs):
  436. return 10.0
  437. def place_order(self, **kwargs):
  438. self.placed_orders.append(kwargs)
  439. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  440. ctx = FakeContext()
  441. strategy = GridStrategy(ctx, {"grid_levels": 2, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  442. strategy.state["center_price"] = 1.3907
  443. strategy.state["seeded"] = True
  444. strategy.state["orders"] = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": "o1"} for _ in range(5)]
  445. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  446. def fake_sync_open_orders_state():
  447. live = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": f"o{i}"} for i in range(5)]
  448. strategy.state["orders"] = live
  449. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  450. strategy.state["open_order_count"] = 5
  451. return live
  452. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  453. monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: True)
  454. monkeypatch.setattr(strategy, "_price", lambda: 1.3915)
  455. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  456. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  457. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  458. result = strategy.on_tick({})
  459. assert result["action"] in {"seed", "reseed"}
  460. assert ctx.cancelled_all == 1
  461. assert len(ctx.placed_orders) > 0
  462. def test_grid_recenters_exactly_on_live_price():
  463. class FakeContext:
  464. base_currency = "XRP"
  465. counter_currency = "USD"
  466. market_symbol = "xrpusd"
  467. minimum_order_value = 10.0
  468. mode = "active"
  469. def cancel_all_orders(self):
  470. return {"ok": True}
  471. def get_fee_rates(self, market):
  472. return {"maker": 0.0, "taker": 0.0}
  473. def suggest_order_amount(self, **kwargs):
  474. return 0.1
  475. def place_order(self, **kwargs):
  476. return {"status": "ok", "id": "oid-1"}
  477. strategy = GridStrategy(FakeContext(), {})
  478. strategy.state["center_price"] = 100.0
  479. strategy._recenter_and_rebuild_from_price(160.0, "test recenter")
  480. assert strategy.state["center_price"] == 160.0
  481. def test_grid_stop_cancels_all_open_orders():
  482. class FakeContext:
  483. base_currency = "XRP"
  484. counter_currency = "USD"
  485. market_symbol = "xrpusd"
  486. minimum_order_value = 10.0
  487. mode = "active"
  488. def __init__(self):
  489. self.cancelled = False
  490. def cancel_all_orders(self):
  491. self.cancelled = True
  492. return {"ok": True}
  493. def get_fee_rates(self, market):
  494. return {"maker": 0.0, "taker": 0.0}
  495. strategy = GridStrategy(FakeContext(), {})
  496. strategy.state["orders"] = [{"id": "o1"}]
  497. strategy.state["order_ids"] = ["o1"]
  498. strategy.state["open_order_count"] = 1
  499. strategy.on_stop()
  500. assert strategy.context.cancelled is True
  501. assert strategy.state["open_order_count"] == 0
  502. assert strategy.state["last_action"] == "stopped"
  503. def test_base_strategy_report_uses_context_snapshot():
  504. class FakeContext:
  505. id = "s-1"
  506. account_id = "acct-1"
  507. market_symbol = "xrpusd"
  508. base_currency = "XRP"
  509. counter_currency = "USD"
  510. mode = "active"
  511. def get_strategy_snapshot(self):
  512. return {
  513. "identity": {"strategy_id": "s-1", "strategy_name": "Demo", "account_id": "acct-1", "market": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"},
  514. "control": {"enabled_state": "on", "mode": "active"},
  515. "position": {"balances": [{"asset_code": "XRP", "available": 1.0}]},
  516. "orders": {"open_orders": [{"id": "o1"}]},
  517. "execution": {"execution_quality": "good"},
  518. }
  519. class DemoStrategy(BaseStrategy):
  520. LABEL = "Demo"
  521. report = DemoStrategy(FakeContext(), {}).report()
  522. assert report["identity"]["strategy_id"] == "s-1"
  523. assert report["control"]["mode"] == "active"
  524. assert report["position"]["open_orders"][0]["id"] == "o1"
  525. def test_dumb_trader_uses_policy_and_reports_fit():
  526. class FakeContext:
  527. id = "s-2"
  528. account_id = "acct-2"
  529. client_id = "cid-2"
  530. mode = "active"
  531. market_symbol = "xrpusd"
  532. base_currency = "XRP"
  533. counter_currency = "USD"
  534. def get_price(self, symbol):
  535. return {"price": 1.2}
  536. def place_order(self, **kwargs):
  537. return {"ok": True, "order": kwargs}
  538. def get_strategy_snapshot(self):
  539. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  540. strat = DumbStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 1.5})
  541. strat.apply_policy()
  542. report = strat.report()
  543. assert report["fit"]["risk_profile"] == "neutral"
  544. assert strat.state["policy_derived"]["order_notional_quote"] > 0
  545. def test_dumb_trader_buys_on_configured_side_without_regime_input():
  546. class FakeContext:
  547. id = "s-bull"
  548. account_id = "acct-1"
  549. client_id = "cid-1"
  550. mode = "active"
  551. market_symbol = "xrpusd"
  552. base_currency = "XRP"
  553. counter_currency = "USD"
  554. def __init__(self):
  555. self.orders = []
  556. def get_price(self, symbol):
  557. return {"price": 1.2}
  558. def place_order(self, **kwargs):
  559. self.orders.append(kwargs)
  560. return {"ok": True, "order": kwargs}
  561. def get_account_info(self):
  562. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  563. minimum_order_value = 10.0
  564. def suggest_order_amount(self, **kwargs):
  565. return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
  566. def get_strategy_snapshot(self):
  567. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  568. ctx = FakeContext()
  569. strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 20.0})
  570. result = strat.on_tick({})
  571. assert result["action"] == "buy"
  572. assert ctx.orders[-1]["side"] == "buy"
  573. assert ctx.orders[-1]["amount"] == 20.0 / 1.2
  574. assert strat.state["last_action"] == "buy_dumb"
  575. def test_dumb_trader_sells_on_configured_side_without_regime_input():
  576. class FakeContext:
  577. id = "s-bear"
  578. account_id = "acct-1"
  579. client_id = "cid-1"
  580. mode = "active"
  581. market_symbol = "xrpusd"
  582. base_currency = "XRP"
  583. counter_currency = "USD"
  584. def __init__(self):
  585. self.orders = []
  586. def get_price(self, symbol):
  587. return {"price": 1.2}
  588. def place_order(self, **kwargs):
  589. self.orders.append(kwargs)
  590. return {"ok": True, "order": kwargs}
  591. def get_account_info(self):
  592. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 10}]}
  593. minimum_order_value = 10.0
  594. def suggest_order_amount(self, **kwargs):
  595. return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
  596. def get_strategy_snapshot(self):
  597. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  598. ctx = FakeContext()
  599. strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 20.0})
  600. result = strat.on_tick({})
  601. assert result["action"] == "sell"
  602. assert ctx.orders[-1]["side"] == "sell"
  603. assert ctx.orders[-1]["amount"] == 20.0 / 1.2
  604. assert strat.state["last_action"] == "sell_dumb"
  605. def test_dumb_trader_buy_only_ignores_bear_regime():
  606. class FakeContext:
  607. id = "s-buy-only"
  608. account_id = "acct-1"
  609. client_id = "cid-1"
  610. mode = "active"
  611. market_symbol = "xrpusd"
  612. base_currency = "XRP"
  613. counter_currency = "USD"
  614. def __init__(self):
  615. self.orders = []
  616. def get_price(self, symbol):
  617. return {"price": 1.2}
  618. def get_regime(self, symbol, timeframe="1h"):
  619. return {
  620. "trend": {"state": "bear", "ema_fast": 1.17, "ema_slow": 1.2},
  621. "momentum": {"state": "bear", "rsi": 36, "macd_histogram": -0.002},
  622. }
  623. def place_order(self, **kwargs):
  624. self.orders.append(kwargs)
  625. return {"ok": True, "order": kwargs}
  626. def get_account_info(self):
  627. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 10}]}
  628. minimum_order_value = 10.0
  629. def suggest_order_amount(self, **kwargs):
  630. return 10.0
  631. def get_strategy_snapshot(self):
  632. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  633. ctx = FakeContext()
  634. strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 20.0})
  635. result = strat.on_tick({})
  636. assert result["action"] == "buy"
  637. assert ctx.orders[-1]["side"] == "buy"
  638. assert strat.state["last_action"] == "buy_dumb"
  639. def test_dumb_trader_sell_only_ignores_bull_regime():
  640. class FakeContext:
  641. id = "s-sell-only"
  642. account_id = "acct-1"
  643. client_id = "cid-1"
  644. mode = "active"
  645. market_symbol = "xrpusd"
  646. base_currency = "XRP"
  647. counter_currency = "USD"
  648. def __init__(self):
  649. self.orders = []
  650. def get_price(self, symbol):
  651. return {"price": 1.2}
  652. def get_regime(self, symbol, timeframe="1h"):
  653. return {
  654. "trend": {"state": "bull", "ema_fast": 1.21, "ema_slow": 1.18},
  655. "momentum": {"state": "bull", "rsi": 64, "macd_histogram": 0.002},
  656. }
  657. def place_order(self, **kwargs):
  658. self.orders.append(kwargs)
  659. return {"ok": True, "order": kwargs}
  660. def get_account_info(self):
  661. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  662. minimum_order_value = 10.0
  663. def suggest_order_amount(self, **kwargs):
  664. return 10.0
  665. def get_strategy_snapshot(self):
  666. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  667. ctx = FakeContext()
  668. strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 20.0})
  669. result = strat.on_tick({})
  670. assert result["action"] == "sell"
  671. assert ctx.orders[-1]["side"] == "sell"
  672. assert strat.state["last_action"] == "sell_dumb"
  673. def test_dumb_trader_policy_does_not_override_explicit_order_notional_quote():
  674. class FakeContext:
  675. id = "s-explicit"
  676. account_id = "acct-1"
  677. client_id = "cid-1"
  678. mode = "active"
  679. market_symbol = "xrpusd"
  680. base_currency = "XRP"
  681. counter_currency = "USD"
  682. def get_price(self, symbol):
  683. return {"price": 1.2}
  684. def get_strategy_snapshot(self):
  685. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  686. strat = DumbStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 10.5})
  687. strat.apply_policy()
  688. assert strat.config["order_notional_quote"] == 10.5
  689. assert strat.state["policy_derived"]["order_notional_quote"] == 10.5
  690. def test_dumb_trader_passes_live_fee_rate_into_sizing_helper():
  691. class FakeContext:
  692. id = "s-fee"
  693. account_id = "acct-1"
  694. client_id = "cid-1"
  695. mode = "active"
  696. market_symbol = "xrpusd"
  697. base_currency = "XRP"
  698. counter_currency = "USD"
  699. minimum_order_value = 10.0
  700. def __init__(self):
  701. self.fee_calls = []
  702. self.suggest_calls = []
  703. def get_price(self, symbol):
  704. return {"price": 1.2}
  705. def get_fee_rates(self, market_symbol=None):
  706. self.fee_calls.append(market_symbol)
  707. return {"maker": 0.0025, "taker": 0.004}
  708. def suggest_order_amount(self, **kwargs):
  709. self.suggest_calls.append(kwargs)
  710. return 8.0
  711. def place_order(self, **kwargs):
  712. return {"ok": True, "order": kwargs}
  713. def get_account_info(self):
  714. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  715. def get_strategy_snapshot(self):
  716. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  717. ctx = FakeContext()
  718. strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 10.5, "dust_collect": True})
  719. strat.on_tick({})
  720. assert ctx.fee_calls == ["xrpusd"]
  721. assert ctx.suggest_calls[-1]["fee_rate"] == 0.0025
  722. assert ctx.suggest_calls[-1]["dust_collect"] is True
  723. def test_grid_sizing_helper_receives_quote_controls_and_dust_collect():
  724. class FakeContext:
  725. base_currency = "XRP"
  726. counter_currency = "USD"
  727. market_symbol = "xrpusd"
  728. minimum_order_value = 10.0
  729. mode = "active"
  730. def __init__(self):
  731. self.suggest_calls = []
  732. def get_fee_rates(self, market_symbol=None):
  733. return {"maker": 0.001, "taker": 0.004}
  734. def suggest_order_amount(self, **kwargs):
  735. self.suggest_calls.append(kwargs)
  736. return 7.0
  737. ctx = FakeContext()
  738. strategy = GridStrategy(
  739. ctx,
  740. {
  741. "grid_levels": 3,
  742. "order_notional_quote": 11.0,
  743. "max_order_notional_quote": 12.0,
  744. "dust_collect": True,
  745. },
  746. )
  747. amount = strategy._suggest_amount("buy", 1.5, 3, 10.0)
  748. assert amount == 7.0
  749. assert ctx.suggest_calls[-1]["fee_rate"] == 0.004
  750. assert ctx.suggest_calls[-1]["quote_notional"] == 11.0
  751. assert ctx.suggest_calls[-1]["max_notional_per_order"] == 12.0
  752. assert ctx.suggest_calls[-1]["dust_collect"] is True
  753. assert ctx.suggest_calls[-1]["levels"] == 3
  754. def test_dumb_trader_buy_uses_requested_notional_even_with_balance_target_configured():
  755. class FakeContext:
  756. id = "s-buy-clamp"
  757. account_id = "acct-1"
  758. client_id = "cid-1"
  759. mode = "active"
  760. market_symbol = "xrpusd"
  761. base_currency = "XRP"
  762. counter_currency = "USD"
  763. minimum_order_value = 0.5
  764. def __init__(self):
  765. self.orders = []
  766. def get_price(self, symbol):
  767. return {"price": 1.0}
  768. def get_fee_rates(self, market_symbol=None):
  769. return {"maker": 0.0, "taker": 0.0}
  770. def suggest_order_amount(self, **kwargs):
  771. return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
  772. def place_order(self, **kwargs):
  773. self.orders.append(kwargs)
  774. return {"ok": True, "order": kwargs}
  775. def get_account_info(self):
  776. return {"balances": [{"asset_code": "USD", "available": 6.0}, {"asset_code": "XRP", "available": 4.0}]}
  777. def get_strategy_snapshot(self):
  778. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  779. ctx = FakeContext()
  780. strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 3.0, "balance_target": 0.5})
  781. result = strat.on_tick({})
  782. assert result["action"] == "buy"
  783. assert ctx.orders[-1]["amount"] == 3.0
  784. def test_dumb_trader_sell_uses_requested_notional_even_with_balance_target_configured():
  785. class FakeContext:
  786. id = "s-sell-clamp"
  787. account_id = "acct-1"
  788. client_id = "cid-1"
  789. mode = "active"
  790. market_symbol = "xrpusd"
  791. base_currency = "XRP"
  792. counter_currency = "USD"
  793. minimum_order_value = 0.5
  794. def __init__(self):
  795. self.orders = []
  796. def get_price(self, symbol):
  797. return {"price": 1.0}
  798. def get_fee_rates(self, market_symbol=None):
  799. return {"maker": 0.0, "taker": 0.0}
  800. def suggest_order_amount(self, **kwargs):
  801. return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
  802. def place_order(self, **kwargs):
  803. self.orders.append(kwargs)
  804. return {"ok": True, "order": kwargs}
  805. def get_account_info(self):
  806. return {"balances": [{"asset_code": "USD", "available": 3.0}, {"asset_code": "XRP", "available": 7.0}]}
  807. def get_strategy_snapshot(self):
  808. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  809. ctx = FakeContext()
  810. strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 5.0, "balance_target": 0.5})
  811. result = strat.on_tick({})
  812. assert result["action"] == "sell"
  813. assert ctx.orders[-1]["amount"] == 5.0
  814. def test_dumb_trader_sell_holds_sub_minimum_order():
  815. class FakeContext:
  816. id = "s-sell-min"
  817. account_id = "acct-1"
  818. client_id = "cid-1"
  819. mode = "active"
  820. market_symbol = "solusd"
  821. base_currency = "SOL"
  822. counter_currency = "USD"
  823. minimum_order_value = 1.0
  824. def __init__(self):
  825. self.orders = []
  826. def get_price(self, symbol):
  827. return {"price": 86.20062}
  828. def get_fee_rates(self, market_symbol=None):
  829. return {"maker": 0.0, "taker": 0.0}
  830. def suggest_order_amount(self, **kwargs):
  831. return 0.001
  832. def place_order(self, **kwargs):
  833. self.orders.append(kwargs)
  834. return {"ok": True, "order": kwargs}
  835. def get_account_info(self):
  836. return {"balances": [{"asset_code": "USD", "available": 1000.0}, {"asset_code": "SOL", "available": 0.00447}]}
  837. def get_strategy_snapshot(self):
  838. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  839. ctx = FakeContext()
  840. strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 5.0, "balance_target": 1.0})
  841. result = strat.on_tick({})
  842. assert result["action"] == "hold"
  843. assert result["reason"] == "no usable size"
  844. assert ctx.orders == []
  845. def test_dumb_trader_holds_when_live_sizing_reports_no_affordable_sell():
  846. class FakeContext:
  847. id = "s-sell-no-funds"
  848. account_id = "acct-1"
  849. client_id = "cid-1"
  850. mode = "active"
  851. market_symbol = "solusd"
  852. base_currency = "SOL"
  853. counter_currency = "USD"
  854. minimum_order_value = 0.5
  855. def __init__(self):
  856. self.orders = []
  857. def get_price(self, symbol):
  858. return {"price": 86.69}
  859. def get_fee_rates(self, market_symbol=None):
  860. return {"maker": 0.0, "taker": 0.0}
  861. def suggest_order_amount(self, **kwargs):
  862. return 0.0
  863. def place_order(self, **kwargs):
  864. self.orders.append(kwargs)
  865. return {"ok": True, "order": kwargs}
  866. def get_account_info(self):
  867. return {"balances": [{"asset_code": "USD", "available": 1000.0}, {"asset_code": "SOL", "available": 0.00447}]}
  868. def get_strategy_snapshot(self):
  869. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  870. ctx = FakeContext()
  871. strat = DumbStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 11.0, "dust_collect": True})
  872. result = strat.on_tick({})
  873. assert result["action"] == "hold"
  874. assert result["reason"] == "no usable size"
  875. assert ctx.orders == []
  876. def test_dumb_trader_holds_when_balance_refresh_fails_after_restart():
  877. class FakeContext:
  878. id = "s-restart-hold"
  879. account_id = "acct-1"
  880. client_id = "cid-1"
  881. mode = "active"
  882. market_symbol = "solusd"
  883. base_currency = "SOL"
  884. counter_currency = "USD"
  885. minimum_order_value = 1.0
  886. def __init__(self):
  887. self.orders = []
  888. def get_price(self, symbol):
  889. return {"price": 86.51}
  890. def get_fee_rates(self, market_symbol=None):
  891. return {"maker": 0.0, "taker": 0.0}
  892. def get_account_info(self):
  893. raise RuntimeError("Bitstamp auth breaker active, retry later")
  894. def suggest_order_amount(self, **kwargs):
  895. raise AssertionError("should not size an order after balance refresh failure")
  896. def place_order(self, **kwargs):
  897. self.orders.append(kwargs)
  898. return {"ok": True, "order": kwargs}
  899. def get_strategy_snapshot(self):
  900. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  901. ctx = FakeContext()
  902. strat = DumbStrategy(
  903. ctx,
  904. {
  905. "trade_side": "sell",
  906. "order_notional_quote": 5.0,
  907. "dust_collect": True,
  908. },
  909. )
  910. strat.state["base_available"] = 0.127153
  911. strat.state["counter_available"] = 0.0
  912. result = strat.on_tick({})
  913. assert result["action"] == "hold"
  914. assert result["reason"] == "balance refresh unavailable"
  915. assert strat.state["balance_snapshot_ok"] is False
  916. assert strat.state["base_available"] == 0.0
  917. assert ctx.orders == []
  918. def test_dumb_trader_holds_when_trade_side_is_symmetrical():
  919. class FakeContext:
  920. id = "s-target-hold"
  921. account_id = "acct-1"
  922. client_id = "cid-1"
  923. mode = "active"
  924. market_symbol = "xrpusd"
  925. base_currency = "XRP"
  926. counter_currency = "USD"
  927. minimum_order_value = 0.5
  928. def get_price(self, symbol):
  929. return {"price": 1.0}
  930. def get_fee_rates(self, market_symbol=None):
  931. return {"maker": 0.0, "taker": 0.0}
  932. def suggest_order_amount(self, **kwargs):
  933. return 10.0
  934. def get_account_info(self):
  935. return {"balances": [{"asset_code": "USD", "available": 5.0}, {"asset_code": "XRP", "available": 5.0}]}
  936. def get_strategy_snapshot(self):
  937. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  938. strat = DumbStrategy(FakeContext(), {"trade_side": "both", "order_notional_quote": 3.0, "balance_target": 0.5})
  939. result = strat.on_tick({})
  940. assert result["action"] == "hold"
  941. assert result["reason"] == "trade_side must be buy or sell"
  942. def test_cap_amount_to_balance_target_caps_sell_to_live_base():
  943. amount = cap_amount_to_balance_target(
  944. suggested_amount=0.127226,
  945. side="sell",
  946. price=86.20062,
  947. fee_rate=0.0,
  948. balance_target=1.0,
  949. base_available=0.00447,
  950. counter_available=0.0,
  951. min_notional=0.0,
  952. )
  953. assert amount == 0.00447
  954. def test_cap_amount_to_balance_target_rejects_sell_below_min_notional():
  955. amount = cap_amount_to_balance_target(
  956. suggested_amount=0.127226,
  957. side="sell",
  958. price=86.20062,
  959. fee_rate=0.0,
  960. balance_target=1.0,
  961. base_available=0.00447,
  962. counter_available=0.0,
  963. min_notional=1.0,
  964. )
  965. assert amount == 0.0
  966. def test_dumb_trader_ignores_balance_target_and_keeps_size():
  967. class FakeContext:
  968. id = "s-target-open"
  969. account_id = "acct-1"
  970. client_id = "cid-1"
  971. mode = "active"
  972. market_symbol = "xrpusd"
  973. base_currency = "XRP"
  974. counter_currency = "USD"
  975. minimum_order_value = 0.5
  976. def __init__(self):
  977. self.orders = []
  978. def get_price(self, symbol):
  979. return {"price": 1.0}
  980. def get_fee_rates(self, market_symbol=None):
  981. return {"maker": 0.0, "taker": 0.0}
  982. def suggest_order_amount(self, **kwargs):
  983. return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
  984. def place_order(self, **kwargs):
  985. self.orders.append(kwargs)
  986. return {"ok": True, "order": kwargs}
  987. def get_account_info(self):
  988. return {"balances": [{"asset_code": "USD", "available": 6.0}, {"asset_code": "XRP", "available": 4.0}]}
  989. def get_strategy_snapshot(self):
  990. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  991. ctx = FakeContext()
  992. strat = DumbStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 3.0, "balance_target": 0.5})
  993. result = strat.on_tick({})
  994. assert result["action"] == "buy"
  995. assert ctx.orders[-1]["amount"] == 3.0
  996. def test_exposure_protector_buy_sizing_respects_fee_when_min_order_quote_is_unaffordable():
  997. class FakeContext:
  998. account_id = "acct-1"
  999. client_id = "cid-1"
  1000. mode = "active"
  1001. market_symbol = "xrpusd"
  1002. base_currency = "XRP"
  1003. counter_currency = "USD"
  1004. def get_fee_rates(self, market_symbol=None):
  1005. return {"maker": 0.1, "taker": 0.2}
  1006. strategy = ExposureStrategy(
  1007. FakeContext(),
  1008. {
  1009. "rebalance_target_ratio": 0.9,
  1010. "rebalance_step_ratio": 1.0,
  1011. "balance_tolerance": 0.0,
  1012. "min_order_notional_quote": 10.0,
  1013. },
  1014. )
  1015. strategy.state["base_available"] = 0.0
  1016. strategy.state["counter_available"] = 10.0
  1017. amount = strategy._suggest_amount("buy", 1.0)
  1018. assert amount == 0.0