test_strategies.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917
  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_sdk import Strategy as BaseStrategy
  9. from strategies.exposure_protector import Strategy as ExposureStrategy
  10. from strategies.grid_trader import Strategy as GridStrategy
  11. from strategies.trend_follower import Strategy as TrendStrategy
  12. STRATEGY_CODE = '''
  13. from src.trader_mcp.strategy_sdk import Strategy
  14. class Strategy(Strategy):
  15. def init(self):
  16. return {"started": True, "config_copy": dict(self.config)}
  17. '''
  18. def test_strategies_endpoints_roundtrip():
  19. with TemporaryDirectory() as tmpdir:
  20. strategy_store.DB_PATH = Path(tmpdir) / "trader_mcp.sqlite3"
  21. from src.trader_mcp import strategy_registry
  22. strategy_registry.STRATEGIES_DIR = Path(tmpdir) / "strategies"
  23. strategy_registry.STRATEGIES_DIR.mkdir()
  24. (strategy_registry.STRATEGIES_DIR / "demo.py").write_text(STRATEGY_CODE)
  25. client = TestClient(app)
  26. r = client.get("/strategies")
  27. assert r.status_code == 200
  28. body = r.json()
  29. assert "available" in body
  30. assert "configured" in body
  31. r = client.post(
  32. "/strategies",
  33. json={
  34. "id": "demo-1",
  35. "strategy_type": "demo",
  36. "account_id": "acct-1",
  37. "client_id": "strategy:test",
  38. "mode": "observe",
  39. "config": {"risk": 0.01},
  40. },
  41. )
  42. assert r.status_code == 200
  43. assert r.json()["id"] == "demo-1"
  44. r = client.get("/strategies")
  45. assert any(item["id"] == "demo-1" for item in r.json()["configured"])
  46. r = client.delete("/strategies/demo-1")
  47. assert r.status_code == 200
  48. assert r.json()["ok"] is True
  49. def test_strategy_context_binds_identity(monkeypatch):
  50. calls = {}
  51. def fake_place_order(arguments):
  52. calls["place_order"] = arguments
  53. return {"ok": True}
  54. def fake_open_orders(account_id, client_id=None):
  55. calls["open_orders"] = {"account_id": account_id, "client_id": client_id}
  56. return {"ok": True}
  57. def fake_cancel_all(account_id, client_id=None):
  58. calls["cancel_all"] = {"account_id": account_id, "client_id": client_id}
  59. return {"ok": True}
  60. monkeypatch.setattr("src.trader_mcp.strategy_context.place_order", fake_place_order)
  61. monkeypatch.setattr("src.trader_mcp.strategy_context.list_open_orders", fake_open_orders)
  62. monkeypatch.setattr("src.trader_mcp.strategy_context.cancel_all_orders", fake_cancel_all)
  63. ctx = StrategyContext(id="inst-1", account_id="acct-1", client_id="client-1", mode="active")
  64. ctx.place_order(side="sell", market="xrpusd", order_type="limit", amount="10", price="2")
  65. ctx.get_open_orders()
  66. ctx.cancel_all_orders()
  67. assert calls["place_order"]["account_id"] == "acct-1"
  68. assert calls["place_order"]["client_id"] == "client-1"
  69. assert calls["open_orders"] == {"account_id": "acct-1", "client_id": "client-1"}
  70. assert calls["cancel_all"] == {"account_id": "acct-1", "client_id": "client-1"}
  71. def test_stop_loss_strategy_loads_with_aligned_regime_config(tmp_path):
  72. original_db = strategy_store.DB_PATH
  73. original_dir = strategy_registry.STRATEGIES_DIR
  74. try:
  75. strategy_store.DB_PATH = tmp_path / "trader_mcp.sqlite3"
  76. strategy_registry.STRATEGIES_DIR = tmp_path / "strategies"
  77. strategy_registry.STRATEGIES_DIR.mkdir()
  78. (strategy_registry.STRATEGIES_DIR / "grid_trader.py").write_text((Path(__file__).resolve().parents[1] / "strategies" / "grid_trader.py").read_text())
  79. (strategy_registry.STRATEGIES_DIR / "exposure_protector.py").write_text((Path(__file__).resolve().parents[1] / "strategies" / "exposure_protector.py").read_text())
  80. grid_defaults = strategy_registry.get_strategy_default_config("grid_trader")
  81. stop_defaults = strategy_registry.get_strategy_default_config("exposure_protector")
  82. assert grid_defaults["trade_sides"] == "both"
  83. assert grid_defaults["grid_step_pct"] == 0.012
  84. assert stop_defaults["trail_distance_pct"] == 0.03
  85. assert stop_defaults["rebalance_target_ratio"] == 0.5
  86. assert stop_defaults["min_rebalance_seconds"] == 180
  87. assert stop_defaults["min_price_move_pct"] == 0.005
  88. finally:
  89. strategy_store.DB_PATH = original_db
  90. strategy_registry.STRATEGIES_DIR = original_dir
  91. def test_grid_supervision_reports_factual_capacity_not_handoff_commands():
  92. class FakeContext:
  93. account_id = "acct-1"
  94. market_symbol = "xrpusd"
  95. base_currency = "XRP"
  96. counter_currency = "USD"
  97. mode = "active"
  98. strategy = GridStrategy(FakeContext(), {})
  99. strategy.state.update({
  100. "last_price": 1.45,
  101. "base_available": 50.0,
  102. "counter_available": 38.7,
  103. "regimes": {"1h": {"trend": {"state": "bull"}}},
  104. })
  105. supervision = strategy._supervision()
  106. assert supervision["inventory_pressure"] == "base_heavy"
  107. assert supervision["capacity_available"] is False
  108. assert supervision["side_capacity"] == {"buy": True, "sell": True}
  109. strategy.state.update({
  110. "base_available": 88.0,
  111. "counter_available": 4.0,
  112. })
  113. supervision = strategy._supervision()
  114. assert supervision["inventory_pressure"] == "base_side_depleted"
  115. assert supervision["side_capacity"] == {"buy": True, "sell": False}
  116. def test_grid_supervision_exposes_adverse_side_open_orders():
  117. class FakeContext:
  118. account_id = "acct-1"
  119. market_symbol = "xrpusd"
  120. base_currency = "XRP"
  121. counter_currency = "USD"
  122. mode = "active"
  123. strategy = GridStrategy(FakeContext(), {})
  124. strategy.state.update({
  125. "last_price": 1.60,
  126. "center_price": 1.45,
  127. "orders": [
  128. {"side": "sell", "price": "1.62", "amount": "10", "status": "open"},
  129. {"side": "sell", "price": "1.66", "amount": "5", "status": "open"},
  130. {"side": "buy", "price": "1.38", "amount": "7", "status": "open"},
  131. ],
  132. })
  133. supervision = strategy._supervision()
  134. assert supervision["market_bias"] == "bullish"
  135. assert supervision["adverse_side"] == "sell"
  136. assert supervision["adverse_side_open_order_count"] == 2
  137. assert supervision["adverse_side_open_order_notional_quote"] > 0
  138. assert "sell ladder exposed" in " ".join(supervision["concerns"])
  139. def test_trend_and_protector_supervision_reports_facts_only():
  140. class FakeContext:
  141. account_id = "acct-1"
  142. market_symbol = "xrpusd"
  143. base_currency = "XRP"
  144. counter_currency = "USD"
  145. mode = "active"
  146. trend = TrendStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 1.0})
  147. trend.state.update({"last_price": 1.45, "base_available": 20.0, "counter_available": 20.0, "last_order_at": 0.0})
  148. trend_supervision = trend._supervision()
  149. assert trend_supervision["trade_side"] == "buy"
  150. assert trend_supervision["capacity_available"] is True
  151. assert trend_supervision["entry_offset_pct"] == 0.003
  152. assert trend_supervision["chasing_risk"] in {"low", "moderate", "elevated"}
  153. assert "switch_readiness" not in trend_supervision
  154. assert "desired_companion" not in trend_supervision
  155. protector = ExposureStrategy(FakeContext(), {})
  156. protector.state.update({"last_price": 1.45, "base_available": 40.0, "counter_available": 10.0})
  157. protector_supervision = protector._supervision()
  158. assert protector_supervision["rebalance_needed"] is True
  159. assert protector_supervision["repair_progress"] <= 1.0
  160. assert "switch_readiness" not in protector_supervision
  161. assert "desired_companion" not in protector_supervision
  162. def test_exposure_protector_holds_inside_hysteresis_band(monkeypatch):
  163. class FakeContext:
  164. account_id = "acct-1"
  165. market_symbol = "xrpusd"
  166. base_currency = "XRP"
  167. counter_currency = "USD"
  168. mode = "active"
  169. def __init__(self):
  170. self.placed_orders = []
  171. def get_account_info(self):
  172. return {"balances": [{"asset_code": "XRP", "available": 9.2}, {"asset_code": "USD", "available": 10.0}]}
  173. def get_price(self, market):
  174. return {"price": 1.0}
  175. def get_fee_rates(self, market):
  176. return {"maker": 0.0, "taker": 0.004}
  177. def place_order(self, **kwargs):
  178. self.placed_orders.append(kwargs)
  179. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  180. ctx = FakeContext()
  181. 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})
  182. strategy.state["last_rebalance_side"] = "sell"
  183. strategy.state["last_order_at"] = 0
  184. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  185. result = strategy.on_tick({})
  186. assert result["action"] == "hold"
  187. assert result["reason"] == "within rebalance hysteresis"
  188. assert ctx.placed_orders == []
  189. def test_grid_apply_policy_keeps_explicit_grid_levels():
  190. class FakeContext:
  191. account_id = "acct-1"
  192. market_symbol = "xrpusd"
  193. base_currency = "XRP"
  194. counter_currency = "USD"
  195. mode = "active"
  196. strategy = GridStrategy(FakeContext(), {"grid_levels": 5, "policy": {"risk_posture": "normal"}})
  197. strategy.apply_policy()
  198. assert strategy.config["grid_levels"] == 5
  199. assert strategy.state["policy_derived"]["grid_levels"] == 5
  200. def test_grid_seed_keeps_other_side_when_one_side_fails(monkeypatch):
  201. class FakeContext:
  202. base_currency = "XRP"
  203. counter_currency = "USD"
  204. market_symbol = "xrpusd"
  205. minimum_order_value = 10.0
  206. mode = "active"
  207. def __init__(self):
  208. self.attempts = []
  209. self.buy_attempts = 0
  210. self.sell_attempts = 0
  211. def get_fee_rates(self, market):
  212. return {"maker": 0.0, "taker": 0.0}
  213. def suggest_order_amount(self, **kwargs):
  214. return 10.0
  215. def place_order(self, **kwargs):
  216. self.attempts.append(kwargs)
  217. if kwargs["side"] == "buy":
  218. self.buy_attempts += 1
  219. if self.buy_attempts == 3:
  220. raise RuntimeError("insufficient USD")
  221. elif kwargs["side"] == "sell":
  222. self.sell_attempts += 1
  223. return {"status": "ok", "id": f"{kwargs['side']}-{len(self.attempts)}"}
  224. ctx = FakeContext()
  225. strategy = GridStrategy(ctx, {"grid_levels": 5, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.0})
  226. strategy.state["center_price"] = 100.0
  227. monkeypatch.setattr(strategy, "_supported_levels", lambda side, center, min_notional: 5)
  228. monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: None)
  229. strategy._place_grid(100.0)
  230. orders = strategy.state["orders"]
  231. assert ctx.buy_attempts == 5
  232. assert ctx.sell_attempts == 5
  233. assert len([o for o in orders if o["side"] == "buy"]) == 4
  234. assert len([o for o in orders if o["side"] == "sell"]) == 5
  235. assert any("partial success" in line for line in (strategy.state.get("debug_log") or [])) or strategy.state.get("last_error") == "insufficient USD"
  236. def test_grid_skips_rebuild_when_balance_refresh_fails(monkeypatch):
  237. class FakeContext:
  238. base_currency = "XRP"
  239. counter_currency = "USD"
  240. market_symbol = "xrpusd"
  241. minimum_order_value = 10.0
  242. mode = "active"
  243. def __init__(self):
  244. self.cancelled_all = 0
  245. self.placed_orders = []
  246. def get_fee_rates(self, market):
  247. return {"maker": 0.0, "taker": 0.004}
  248. def get_account_info(self):
  249. raise RuntimeError("Bitstamp auth breaker active, retry later")
  250. def cancel_all_orders(self):
  251. self.cancelled_all += 1
  252. return {"ok": True}
  253. def suggest_order_amount(self, **kwargs):
  254. return 10.0
  255. def place_order(self, **kwargs):
  256. self.placed_orders.append(kwargs)
  257. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  258. ctx = FakeContext()
  259. strategy = GridStrategy(ctx, {"grid_levels": 5, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  260. strategy.state["center_price"] = 1.4397
  261. strategy.state["seeded"] = True
  262. strategy.state["orders"] = [{"side": "buy", "price": 1.43, "amount": 10.0, "id": "o1"}]
  263. strategy.state["order_ids"] = ["o1"]
  264. monkeypatch.setattr(strategy, "_sync_open_orders_state", lambda: [{"side": "buy", "price": 1.43, "amount": 10.0, "id": "o1"}])
  265. monkeypatch.setattr(strategy, "_price", lambda: 1.4397)
  266. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  267. result = strategy.on_tick({})
  268. assert result["action"] == "hold"
  269. assert result["reason"] == "balance refresh unavailable"
  270. assert ctx.cancelled_all == 0
  271. assert ctx.placed_orders == []
  272. def test_grid_skips_shape_rebuild_when_balance_reads_turn_inconclusive(monkeypatch):
  273. class FakeContext:
  274. base_currency = "XRP"
  275. counter_currency = "USD"
  276. market_symbol = "xrpusd"
  277. minimum_order_value = 10.0
  278. mode = "active"
  279. def __init__(self):
  280. self.cancelled_all = 0
  281. self.placed_orders = []
  282. self.calls = 0
  283. def get_fee_rates(self, market):
  284. return {"maker": 0.0, "taker": 0.004}
  285. def get_account_info(self):
  286. self.calls += 1
  287. if self.calls == 1:
  288. return {
  289. "balances": [
  290. {"asset_code": "USD", "available": 41.29},
  291. {"asset_code": "XRP", "available": 9.98954},
  292. ]
  293. }
  294. raise RuntimeError("Bitstamp auth breaker active, retry later")
  295. def cancel_all_orders(self):
  296. self.cancelled_all += 1
  297. return {"ok": True}
  298. def suggest_order_amount(self, **kwargs):
  299. return 10.0
  300. def place_order(self, **kwargs):
  301. self.placed_orders.append(kwargs)
  302. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  303. ctx = FakeContext()
  304. strategy = GridStrategy(ctx, {"grid_levels": 2, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  305. strategy.state["center_price"] = 1.3285
  306. strategy.state["seeded"] = True
  307. strategy.state["orders"] = [
  308. {"side": "buy", "price": 1.3243993, "amount": 7.63, "id": "existing-buy"},
  309. {"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"},
  310. {"side": "sell", "price": 1.3367011, "amount": 9.0, "id": "sell-2"},
  311. ]
  312. strategy.state["order_ids"] = ["existing-buy", "sell-1", "sell-2"]
  313. def fake_sync_open_orders_state():
  314. live = [{"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"}]
  315. strategy.state["orders"] = live
  316. strategy.state["order_ids"] = ["sell-1"]
  317. strategy.state["open_order_count"] = 1
  318. return live
  319. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  320. monkeypatch.setattr(strategy, "_price", lambda: 1.3285)
  321. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  322. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  323. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  324. result = strategy.on_tick({})
  325. assert result["action"] == "hold"
  326. assert ctx.cancelled_all == 0
  327. assert ctx.placed_orders == []
  328. def test_grid_missing_order_triggers_full_rebuild(monkeypatch):
  329. class FakeContext:
  330. base_currency = "XRP"
  331. counter_currency = "USD"
  332. market_symbol = "xrpusd"
  333. minimum_order_value = 10.0
  334. mode = "active"
  335. def __init__(self):
  336. self.cancelled_all = 0
  337. self.placed_orders = []
  338. def get_fee_rates(self, market):
  339. return {"maker": 0.0, "taker": 0.004}
  340. def get_account_info(self):
  341. return {
  342. "balances": [
  343. {"asset_code": "USD", "available": 13.55},
  344. {"asset_code": "XRP", "available": 22.0103},
  345. ]
  346. }
  347. def suggest_order_amount(
  348. self,
  349. *,
  350. side,
  351. price,
  352. levels,
  353. min_notional,
  354. fee_rate,
  355. max_notional_per_order=0.0,
  356. dust_collect=False,
  357. order_size=0.0,
  358. safety=0.995,
  359. ):
  360. if side == "buy":
  361. quote_available = 13.55
  362. spendable_quote = quote_available * safety
  363. quote_cap = min(spendable_quote, max_notional_per_order) if max_notional_per_order > 0 else spendable_quote
  364. if quote_cap < min_notional * (1 + fee_rate):
  365. return 0.0
  366. return quote_cap / (price * (1 + fee_rate))
  367. return 0.0
  368. def cancel_all_orders(self):
  369. self.cancelled_all += 1
  370. return {"ok": True}
  371. def place_order(self, **kwargs):
  372. self.placed_orders.append(kwargs)
  373. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  374. ctx = FakeContext()
  375. strategy = GridStrategy(
  376. ctx,
  377. {
  378. "grid_levels": 2,
  379. "grid_step_pct": 0.0062,
  380. "grid_step_min_pct": 0.0033,
  381. "grid_step_max_pct": 0.012,
  382. "max_notional_per_order": 12,
  383. "order_call_delay_ms": 0,
  384. "trade_sides": "both",
  385. "debug_orders": True,
  386. "dust_collect": True,
  387. "enable_trend_guard": False,
  388. "fee_rate": 0.004,
  389. },
  390. )
  391. strategy.state["center_price"] = 1.3285
  392. strategy.state["seeded"] = True
  393. strategy.state["base_available"] = 22.0103
  394. strategy.state["counter_available"] = 13.55
  395. strategy.state["orders"] = [
  396. {"side": "buy", "price": 1.3243993, "amount": 7.63, "id": "existing-buy"},
  397. {"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"},
  398. {"side": "sell", "price": 1.3367011, "amount": 9.0, "id": "sell-2"},
  399. ]
  400. strategy.state["order_ids"] = ["existing-buy", "sell-1", "sell-2"]
  401. def fake_sync_open_orders_state():
  402. live = [{"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"}]
  403. strategy.state["orders"] = live
  404. strategy.state["order_ids"] = ["sell-1"]
  405. strategy.state["open_order_count"] = 1
  406. return live
  407. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  408. monkeypatch.setattr(strategy, "_price", lambda: 1.3285)
  409. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  410. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  411. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  412. result = strategy.on_tick({})
  413. assert result["action"] in {"seed", "reseed"}
  414. assert ctx.cancelled_all == 1
  415. assert len(ctx.placed_orders) > 0
  416. assert strategy.state["last_action"] == "reseeded"
  417. def test_grid_side_imbalance_triggers_full_rebuild(monkeypatch):
  418. class FakeContext:
  419. base_currency = "XRP"
  420. counter_currency = "USD"
  421. market_symbol = "xrpusd"
  422. minimum_order_value = 10.0
  423. mode = "active"
  424. def __init__(self):
  425. self.cancelled_all = 0
  426. self.placed_orders = []
  427. def get_fee_rates(self, market):
  428. return {"maker": 0.0, "taker": 0.004}
  429. def get_account_info(self):
  430. return {"balances": [{"asset_code": "USD", "available": 41.29}, {"asset_code": "XRP", "available": 9.98954}]}
  431. def cancel_all_orders(self):
  432. self.cancelled_all += 1
  433. return {"ok": True}
  434. def suggest_order_amount(self, **kwargs):
  435. return 10.0
  436. def place_order(self, **kwargs):
  437. self.placed_orders.append(kwargs)
  438. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  439. ctx = FakeContext()
  440. strategy = GridStrategy(ctx, {"grid_levels": 2, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  441. strategy.state["center_price"] = 1.3907
  442. strategy.state["seeded"] = True
  443. strategy.state["orders"] = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": "o1"} for _ in range(5)]
  444. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  445. def fake_sync_open_orders_state():
  446. live = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": f"o{i}"} for i in range(5)]
  447. strategy.state["orders"] = live
  448. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  449. strategy.state["open_order_count"] = 5
  450. return live
  451. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  452. monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: True)
  453. monkeypatch.setattr(strategy, "_price", lambda: 1.3915)
  454. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  455. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  456. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  457. result = strategy.on_tick({})
  458. assert result["action"] in {"seed", "reseed"}
  459. assert ctx.cancelled_all == 1
  460. assert len(ctx.placed_orders) > 0
  461. def test_grid_recenters_exactly_on_live_price():
  462. class FakeContext:
  463. base_currency = "XRP"
  464. counter_currency = "USD"
  465. market_symbol = "xrpusd"
  466. minimum_order_value = 10.0
  467. mode = "active"
  468. def cancel_all_orders(self):
  469. return {"ok": True}
  470. def get_fee_rates(self, market):
  471. return {"maker": 0.0, "taker": 0.0}
  472. def suggest_order_amount(self, **kwargs):
  473. return 0.1
  474. def place_order(self, **kwargs):
  475. return {"status": "ok", "id": "oid-1"}
  476. strategy = GridStrategy(FakeContext(), {})
  477. strategy.state["center_price"] = 100.0
  478. strategy._recenter_and_rebuild_from_price(160.0, "test recenter")
  479. assert strategy.state["center_price"] == 160.0
  480. def test_grid_stop_cancels_all_open_orders():
  481. class FakeContext:
  482. base_currency = "XRP"
  483. counter_currency = "USD"
  484. market_symbol = "xrpusd"
  485. minimum_order_value = 10.0
  486. mode = "active"
  487. def __init__(self):
  488. self.cancelled = False
  489. def cancel_all_orders(self):
  490. self.cancelled = True
  491. return {"ok": True}
  492. def get_fee_rates(self, market):
  493. return {"maker": 0.0, "taker": 0.0}
  494. strategy = GridStrategy(FakeContext(), {})
  495. strategy.state["orders"] = [{"id": "o1"}]
  496. strategy.state["order_ids"] = ["o1"]
  497. strategy.state["open_order_count"] = 1
  498. strategy.on_stop()
  499. assert strategy.context.cancelled is True
  500. assert strategy.state["open_order_count"] == 0
  501. assert strategy.state["last_action"] == "stopped"
  502. def test_base_strategy_report_uses_context_snapshot():
  503. class FakeContext:
  504. id = "s-1"
  505. account_id = "acct-1"
  506. market_symbol = "xrpusd"
  507. base_currency = "XRP"
  508. counter_currency = "USD"
  509. mode = "active"
  510. def get_strategy_snapshot(self):
  511. return {
  512. "identity": {"strategy_id": "s-1", "strategy_name": "Demo", "account_id": "acct-1", "market": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"},
  513. "control": {"enabled_state": "on", "mode": "active"},
  514. "position": {"balances": [{"asset_code": "XRP", "available": 1.0}]},
  515. "orders": {"open_orders": [{"id": "o1"}]},
  516. "execution": {"execution_quality": "good"},
  517. }
  518. class DemoStrategy(BaseStrategy):
  519. LABEL = "Demo"
  520. report = DemoStrategy(FakeContext(), {}).report()
  521. assert report["identity"]["strategy_id"] == "s-1"
  522. assert report["control"]["mode"] == "active"
  523. assert report["position"]["open_orders"][0]["id"] == "o1"
  524. def test_trend_follower_uses_policy_and_reports_fit():
  525. class FakeContext:
  526. id = "s-2"
  527. account_id = "acct-2"
  528. client_id = "cid-2"
  529. mode = "active"
  530. market_symbol = "xrpusd"
  531. base_currency = "XRP"
  532. counter_currency = "USD"
  533. def get_price(self, symbol):
  534. return {"price": 1.2}
  535. def place_order(self, **kwargs):
  536. return {"ok": True, "order": kwargs}
  537. def get_strategy_snapshot(self):
  538. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  539. strat = TrendStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 1.5})
  540. strat.apply_policy()
  541. report = strat.report()
  542. assert report["fit"]["risk_profile"] == "growth"
  543. assert strat.state["policy_derived"]["order_notional_quote"] > 0
  544. def test_trend_follower_buys_from_bull_regime_without_explicit_strength():
  545. class FakeContext:
  546. id = "s-bull"
  547. account_id = "acct-1"
  548. client_id = "cid-1"
  549. mode = "active"
  550. market_symbol = "xrpusd"
  551. base_currency = "XRP"
  552. counter_currency = "USD"
  553. def __init__(self):
  554. self.orders = []
  555. def get_price(self, symbol):
  556. return {"price": 1.2}
  557. def place_order(self, **kwargs):
  558. self.orders.append(kwargs)
  559. return {"ok": True, "order": kwargs}
  560. def get_account_info(self):
  561. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  562. minimum_order_value = 10.0
  563. def suggest_order_amount(self, **kwargs):
  564. return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
  565. def get_strategy_snapshot(self):
  566. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  567. ctx = FakeContext()
  568. strat = TrendStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 2.0})
  569. result = strat.on_tick({})
  570. assert result["action"] == "buy"
  571. assert ctx.orders[-1]["side"] == "buy"
  572. assert ctx.orders[-1]["amount"] == 2.0 / 1.2
  573. assert strat.state["last_action"] == "buy_trend"
  574. def test_trend_follower_sells_from_bear_regime_without_explicit_strength():
  575. class FakeContext:
  576. id = "s-bear"
  577. account_id = "acct-1"
  578. client_id = "cid-1"
  579. mode = "active"
  580. market_symbol = "xrpusd"
  581. base_currency = "XRP"
  582. counter_currency = "USD"
  583. def __init__(self):
  584. self.orders = []
  585. def get_price(self, symbol):
  586. return {"price": 1.2}
  587. def place_order(self, **kwargs):
  588. self.orders.append(kwargs)
  589. return {"ok": True, "order": kwargs}
  590. def get_account_info(self):
  591. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 10}]}
  592. minimum_order_value = 10.0
  593. def suggest_order_amount(self, **kwargs):
  594. return float(kwargs.get("quote_notional") or 0.0) / float(kwargs.get("price") or 1.0)
  595. def get_strategy_snapshot(self):
  596. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  597. ctx = FakeContext()
  598. strat = TrendStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 2.0})
  599. result = strat.on_tick({})
  600. assert result["action"] == "sell"
  601. assert ctx.orders[-1]["side"] == "sell"
  602. assert ctx.orders[-1]["amount"] == 2.0 / 1.2
  603. assert strat.state["last_action"] == "sell_trend"
  604. def test_trend_follower_buy_only_ignores_bear_regime():
  605. class FakeContext:
  606. id = "s-buy-only"
  607. account_id = "acct-1"
  608. client_id = "cid-1"
  609. mode = "active"
  610. market_symbol = "xrpusd"
  611. base_currency = "XRP"
  612. counter_currency = "USD"
  613. def __init__(self):
  614. self.orders = []
  615. def get_price(self, symbol):
  616. return {"price": 1.2}
  617. def get_regime(self, symbol, timeframe="1h"):
  618. return {
  619. "trend": {"state": "bear", "ema_fast": 1.17, "ema_slow": 1.2},
  620. "momentum": {"state": "bear", "rsi": 36, "macd_histogram": -0.002},
  621. }
  622. def place_order(self, **kwargs):
  623. self.orders.append(kwargs)
  624. return {"ok": True, "order": kwargs}
  625. def get_account_info(self):
  626. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 10}]}
  627. minimum_order_value = 10.0
  628. def suggest_order_amount(self, **kwargs):
  629. return 6.0
  630. def get_strategy_snapshot(self):
  631. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  632. ctx = FakeContext()
  633. strat = TrendStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 2.0})
  634. result = strat.on_tick({})
  635. assert result["action"] == "buy"
  636. assert ctx.orders[-1]["side"] == "buy"
  637. assert strat.state["last_action"] == "buy_trend"
  638. def test_trend_follower_sell_only_ignores_bull_regime():
  639. class FakeContext:
  640. id = "s-sell-only"
  641. account_id = "acct-1"
  642. client_id = "cid-1"
  643. mode = "active"
  644. market_symbol = "xrpusd"
  645. base_currency = "XRP"
  646. counter_currency = "USD"
  647. def __init__(self):
  648. self.orders = []
  649. def get_price(self, symbol):
  650. return {"price": 1.2}
  651. def get_regime(self, symbol, timeframe="1h"):
  652. return {
  653. "trend": {"state": "bull", "ema_fast": 1.21, "ema_slow": 1.18},
  654. "momentum": {"state": "bull", "rsi": 64, "macd_histogram": 0.002},
  655. }
  656. def place_order(self, **kwargs):
  657. self.orders.append(kwargs)
  658. return {"ok": True, "order": kwargs}
  659. def get_account_info(self):
  660. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  661. minimum_order_value = 10.0
  662. def suggest_order_amount(self, **kwargs):
  663. return 10.0
  664. def get_strategy_snapshot(self):
  665. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  666. ctx = FakeContext()
  667. strat = TrendStrategy(ctx, {"trade_side": "sell", "order_notional_quote": 2.0})
  668. result = strat.on_tick({})
  669. assert result["action"] == "sell"
  670. assert ctx.orders[-1]["side"] == "sell"
  671. assert strat.state["last_action"] == "sell_trend"
  672. def test_trend_follower_policy_does_not_override_explicit_order_notional_quote():
  673. class FakeContext:
  674. id = "s-explicit"
  675. account_id = "acct-1"
  676. client_id = "cid-1"
  677. mode = "active"
  678. market_symbol = "xrpusd"
  679. base_currency = "XRP"
  680. counter_currency = "USD"
  681. def get_price(self, symbol):
  682. return {"price": 1.2}
  683. def get_strategy_snapshot(self):
  684. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  685. strat = TrendStrategy(FakeContext(), {"trade_side": "buy", "order_notional_quote": 10.5})
  686. strat.apply_policy()
  687. assert strat.config["order_notional_quote"] == 10.5
  688. assert strat.state["policy_derived"]["order_notional_quote"] == 10.5
  689. def test_trend_follower_passes_live_fee_rate_into_sizing_helper():
  690. class FakeContext:
  691. id = "s-fee"
  692. account_id = "acct-1"
  693. client_id = "cid-1"
  694. mode = "active"
  695. market_symbol = "xrpusd"
  696. base_currency = "XRP"
  697. counter_currency = "USD"
  698. minimum_order_value = 10.0
  699. def __init__(self):
  700. self.fee_calls = []
  701. self.suggest_calls = []
  702. def get_price(self, symbol):
  703. return {"price": 1.2}
  704. def get_fee_rates(self, market_symbol=None):
  705. self.fee_calls.append(market_symbol)
  706. return {"maker": 0.0025, "taker": 0.004}
  707. def suggest_order_amount(self, **kwargs):
  708. self.suggest_calls.append(kwargs)
  709. return 8.0
  710. def place_order(self, **kwargs):
  711. return {"ok": True, "order": kwargs}
  712. def get_account_info(self):
  713. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  714. def get_strategy_snapshot(self):
  715. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  716. ctx = FakeContext()
  717. strat = TrendStrategy(ctx, {"trade_side": "buy", "order_notional_quote": 10.5})
  718. strat.on_tick({})
  719. assert ctx.fee_calls == ["xrpusd"]
  720. assert ctx.suggest_calls[-1]["fee_rate"] == 0.0025