test_strategies.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706
  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_trend_and_protector_supervision_reports_facts_only():
  117. class FakeContext:
  118. account_id = "acct-1"
  119. market_symbol = "xrpusd"
  120. base_currency = "XRP"
  121. counter_currency = "USD"
  122. mode = "active"
  123. trend = TrendStrategy(FakeContext(), {})
  124. trend.state.update({"last_price": 1.45, "last_strength": 0.42, "last_signal": "up", "base_available": 20.0, "counter_available": 20.0})
  125. trend_supervision = trend._supervision()
  126. assert trend_supervision["trend_strength"] == 0.42
  127. assert trend_supervision["signal"] == "up"
  128. assert "switch_readiness" not in trend_supervision
  129. assert "desired_companion" not in trend_supervision
  130. protector = ExposureStrategy(FakeContext(), {})
  131. protector.state.update({"last_price": 1.45, "base_available": 40.0, "counter_available": 10.0})
  132. protector_supervision = protector._supervision()
  133. assert protector_supervision["rebalance_needed"] is True
  134. assert "switch_readiness" not in protector_supervision
  135. assert "desired_companion" not in protector_supervision
  136. def test_exposure_protector_holds_inside_hysteresis_band(monkeypatch):
  137. class FakeContext:
  138. account_id = "acct-1"
  139. market_symbol = "xrpusd"
  140. base_currency = "XRP"
  141. counter_currency = "USD"
  142. mode = "active"
  143. def __init__(self):
  144. self.placed_orders = []
  145. def get_account_info(self):
  146. return {"balances": [{"asset_code": "XRP", "available": 9.2}, {"asset_code": "USD", "available": 10.0}]}
  147. def get_price(self, market):
  148. return {"price": 1.0}
  149. def get_fee_rates(self, market):
  150. return {"maker": 0.0, "taker": 0.004}
  151. def place_order(self, **kwargs):
  152. self.placed_orders.append(kwargs)
  153. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  154. ctx = FakeContext()
  155. 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})
  156. strategy.state["last_rebalance_side"] = "sell"
  157. strategy.state["last_order_at"] = 0
  158. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  159. result = strategy.on_tick({})
  160. assert result["action"] == "hold"
  161. assert result["reason"] == "within rebalance hysteresis"
  162. assert ctx.placed_orders == []
  163. def test_grid_apply_policy_keeps_explicit_grid_levels():
  164. class FakeContext:
  165. account_id = "acct-1"
  166. market_symbol = "xrpusd"
  167. base_currency = "XRP"
  168. counter_currency = "USD"
  169. mode = "active"
  170. strategy = GridStrategy(FakeContext(), {"grid_levels": 5, "policy": {"risk_posture": "normal"}})
  171. strategy.apply_policy()
  172. assert strategy.config["grid_levels"] == 5
  173. assert strategy.state["policy_derived"]["grid_levels"] == 5
  174. def test_grid_skips_rebuild_when_balance_refresh_fails(monkeypatch):
  175. class FakeContext:
  176. base_currency = "XRP"
  177. counter_currency = "USD"
  178. market_symbol = "xrpusd"
  179. minimum_order_value = 10.0
  180. mode = "active"
  181. def __init__(self):
  182. self.cancelled_all = 0
  183. self.placed_orders = []
  184. def get_fee_rates(self, market):
  185. return {"maker": 0.0, "taker": 0.004}
  186. def get_account_info(self):
  187. raise RuntimeError("Bitstamp auth breaker active, retry later")
  188. def cancel_all_orders(self):
  189. self.cancelled_all += 1
  190. return {"ok": True}
  191. def suggest_order_amount(self, **kwargs):
  192. return 10.0
  193. def place_order(self, **kwargs):
  194. self.placed_orders.append(kwargs)
  195. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  196. ctx = FakeContext()
  197. strategy = GridStrategy(ctx, {"grid_levels": 5, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  198. strategy.state["center_price"] = 1.4397
  199. strategy.state["seeded"] = True
  200. strategy.state["orders"] = [{"side": "buy", "price": 1.43, "amount": 10.0, "id": "o1"}]
  201. strategy.state["order_ids"] = ["o1"]
  202. monkeypatch.setattr(strategy, "_sync_open_orders_state", lambda: [{"side": "buy", "price": 1.43, "amount": 10.0, "id": "o1"}])
  203. monkeypatch.setattr(strategy, "_price", lambda: 1.4397)
  204. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  205. result = strategy.on_tick({})
  206. assert result["action"] == "hold"
  207. assert result["reason"] == "balance refresh unavailable"
  208. assert ctx.cancelled_all == 0
  209. assert ctx.placed_orders == []
  210. def test_grid_missing_order_triggers_full_rebuild(monkeypatch):
  211. class FakeContext:
  212. base_currency = "XRP"
  213. counter_currency = "USD"
  214. market_symbol = "xrpusd"
  215. minimum_order_value = 10.0
  216. mode = "active"
  217. def __init__(self):
  218. self.cancelled_all = 0
  219. self.placed_orders = []
  220. def get_fee_rates(self, market):
  221. return {"maker": 0.0, "taker": 0.004}
  222. def get_account_info(self):
  223. return {
  224. "balances": [
  225. {"asset_code": "USD", "available": 13.55},
  226. {"asset_code": "XRP", "available": 22.0103},
  227. ]
  228. }
  229. def suggest_order_amount(
  230. self,
  231. *,
  232. side,
  233. price,
  234. levels,
  235. min_notional,
  236. fee_rate,
  237. max_notional_per_order=0.0,
  238. dust_collect=False,
  239. order_size=0.0,
  240. safety=0.995,
  241. ):
  242. if side == "buy":
  243. quote_available = 13.55
  244. spendable_quote = quote_available * safety
  245. quote_cap = min(spendable_quote, max_notional_per_order) if max_notional_per_order > 0 else spendable_quote
  246. if quote_cap < min_notional * (1 + fee_rate):
  247. return 0.0
  248. return quote_cap / (price * (1 + fee_rate))
  249. return 0.0
  250. def cancel_all_orders(self):
  251. self.cancelled_all += 1
  252. return {"ok": True}
  253. def place_order(self, **kwargs):
  254. self.placed_orders.append(kwargs)
  255. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  256. ctx = FakeContext()
  257. strategy = GridStrategy(
  258. ctx,
  259. {
  260. "grid_levels": 2,
  261. "grid_step_pct": 0.0062,
  262. "grid_step_min_pct": 0.0033,
  263. "grid_step_max_pct": 0.012,
  264. "max_notional_per_order": 12,
  265. "order_call_delay_ms": 0,
  266. "trade_sides": "both",
  267. "debug_orders": True,
  268. "dust_collect": True,
  269. "enable_trend_guard": False,
  270. "fee_rate": 0.004,
  271. },
  272. )
  273. strategy.state["center_price"] = 1.3285
  274. strategy.state["seeded"] = True
  275. strategy.state["base_available"] = 22.0103
  276. strategy.state["counter_available"] = 13.55
  277. strategy.state["orders"] = [
  278. {"side": "buy", "price": 1.3243993, "amount": 7.63, "id": "existing-buy"},
  279. {"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"},
  280. {"side": "sell", "price": 1.3367011, "amount": 9.0, "id": "sell-2"},
  281. ]
  282. strategy.state["order_ids"] = ["existing-buy", "sell-1", "sell-2"]
  283. def fake_sync_open_orders_state():
  284. live = [{"side": "sell", "price": 1.3326007, "amount": 9.0, "id": "sell-1"}]
  285. strategy.state["orders"] = live
  286. strategy.state["order_ids"] = ["sell-1"]
  287. strategy.state["open_order_count"] = 1
  288. return live
  289. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  290. monkeypatch.setattr(strategy, "_price", lambda: 1.3285)
  291. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  292. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  293. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  294. result = strategy.on_tick({})
  295. assert result["action"] in {"seed", "reseed"}
  296. assert ctx.cancelled_all == 1
  297. assert len(ctx.placed_orders) > 0
  298. assert strategy.state["last_action"] == "reseeded"
  299. def test_grid_side_imbalance_triggers_full_rebuild(monkeypatch):
  300. class FakeContext:
  301. base_currency = "XRP"
  302. counter_currency = "USD"
  303. market_symbol = "xrpusd"
  304. minimum_order_value = 10.0
  305. mode = "active"
  306. def __init__(self):
  307. self.cancelled_all = 0
  308. self.placed_orders = []
  309. def get_fee_rates(self, market):
  310. return {"maker": 0.0, "taker": 0.004}
  311. def get_account_info(self):
  312. return {"balances": [{"asset_code": "USD", "available": 41.29}, {"asset_code": "XRP", "available": 9.98954}]}
  313. def cancel_all_orders(self):
  314. self.cancelled_all += 1
  315. return {"ok": True}
  316. def suggest_order_amount(self, **kwargs):
  317. return 10.0
  318. def place_order(self, **kwargs):
  319. self.placed_orders.append(kwargs)
  320. return {"status": "ok", "id": f"oid-{len(self.placed_orders)}"}
  321. ctx = FakeContext()
  322. strategy = GridStrategy(ctx, {"grid_levels": 2, "order_call_delay_ms": 0, "enable_trend_guard": False, "fee_rate": 0.004})
  323. strategy.state["center_price"] = 1.3907
  324. strategy.state["seeded"] = True
  325. strategy.state["orders"] = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": "o1"} for _ in range(5)]
  326. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  327. def fake_sync_open_orders_state():
  328. live = [{"side": "buy", "price": 1.3800, "amount": 10.0, "id": f"o{i}"} for i in range(5)]
  329. strategy.state["orders"] = live
  330. strategy.state["order_ids"] = [f"o{i}" for i in range(5)]
  331. strategy.state["open_order_count"] = 5
  332. return live
  333. monkeypatch.setattr(strategy, "_sync_open_orders_state", fake_sync_open_orders_state)
  334. monkeypatch.setattr(strategy, "_refresh_balance_snapshot", lambda: True)
  335. monkeypatch.setattr(strategy, "_price", lambda: 1.3915)
  336. monkeypatch.setattr(strategy, "_refresh_regimes", lambda: None)
  337. monkeypatch.setattr(strategy, "_grid_refresh_paused", lambda: False)
  338. monkeypatch.setattr(strategy, "_recenter_threshold_pct", lambda: 0.5)
  339. result = strategy.on_tick({})
  340. assert result["action"] in {"seed", "reseed"}
  341. assert ctx.cancelled_all == 1
  342. assert len(ctx.placed_orders) > 0
  343. def test_grid_recenters_exactly_on_live_price():
  344. class FakeContext:
  345. base_currency = "XRP"
  346. counter_currency = "USD"
  347. market_symbol = "xrpusd"
  348. minimum_order_value = 10.0
  349. mode = "active"
  350. def cancel_all_orders(self):
  351. return {"ok": True}
  352. def get_fee_rates(self, market):
  353. return {"maker": 0.0, "taker": 0.0}
  354. def suggest_order_amount(self, **kwargs):
  355. return 0.1
  356. def place_order(self, **kwargs):
  357. return {"status": "ok", "id": "oid-1"}
  358. strategy = GridStrategy(FakeContext(), {})
  359. strategy.state["center_price"] = 100.0
  360. strategy._recenter_and_rebuild_from_price(160.0, "test recenter")
  361. assert strategy.state["center_price"] == 160.0
  362. def test_grid_stop_cancels_all_open_orders():
  363. class FakeContext:
  364. base_currency = "XRP"
  365. counter_currency = "USD"
  366. market_symbol = "xrpusd"
  367. minimum_order_value = 10.0
  368. mode = "active"
  369. def __init__(self):
  370. self.cancelled = False
  371. def cancel_all_orders(self):
  372. self.cancelled = True
  373. return {"ok": True}
  374. def get_fee_rates(self, market):
  375. return {"maker": 0.0, "taker": 0.0}
  376. strategy = GridStrategy(FakeContext(), {})
  377. strategy.state["orders"] = [{"id": "o1"}]
  378. strategy.state["order_ids"] = ["o1"]
  379. strategy.state["open_order_count"] = 1
  380. strategy.on_stop()
  381. assert strategy.context.cancelled is True
  382. assert strategy.state["open_order_count"] == 0
  383. assert strategy.state["last_action"] == "stopped"
  384. def test_base_strategy_report_uses_context_snapshot():
  385. class FakeContext:
  386. id = "s-1"
  387. account_id = "acct-1"
  388. market_symbol = "xrpusd"
  389. base_currency = "XRP"
  390. counter_currency = "USD"
  391. mode = "active"
  392. def get_strategy_snapshot(self):
  393. return {
  394. "identity": {"strategy_id": "s-1", "strategy_name": "Demo", "account_id": "acct-1", "market": "xrpusd", "base_currency": "XRP", "quote_currency": "USD"},
  395. "control": {"enabled_state": "on", "mode": "active"},
  396. "position": {"balances": [{"asset_code": "XRP", "available": 1.0}]},
  397. "orders": {"open_orders": [{"id": "o1"}]},
  398. "execution": {"execution_quality": "good"},
  399. }
  400. class DemoStrategy(BaseStrategy):
  401. LABEL = "Demo"
  402. report = DemoStrategy(FakeContext(), {}).report()
  403. assert report["identity"]["strategy_id"] == "s-1"
  404. assert report["control"]["mode"] == "active"
  405. assert report["position"]["open_orders"][0]["id"] == "o1"
  406. def test_trend_follower_uses_policy_and_reports_fit():
  407. class FakeContext:
  408. id = "s-2"
  409. account_id = "acct-2"
  410. client_id = "cid-2"
  411. mode = "active"
  412. market_symbol = "xrpusd"
  413. base_currency = "XRP"
  414. counter_currency = "USD"
  415. def get_price(self, symbol):
  416. return {"price": 1.2}
  417. def get_regime(self, symbol, timeframe="1h"):
  418. return {"trend": {"state": "bull", "strength": 0.9}}
  419. def place_order(self, **kwargs):
  420. return {"ok": True, "order": kwargs}
  421. def get_strategy_snapshot(self):
  422. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  423. strat = TrendStrategy(FakeContext(), {"order_size": 1.5})
  424. strat.apply_policy()
  425. report = strat.report()
  426. assert report["fit"]["risk_profile"] == "growth"
  427. assert strat.state["policy_derived"]["order_size"] > 0
  428. def test_trend_follower_buys_from_bull_regime_without_explicit_strength():
  429. class FakeContext:
  430. id = "s-bull"
  431. account_id = "acct-1"
  432. client_id = "cid-1"
  433. mode = "active"
  434. market_symbol = "xrpusd"
  435. base_currency = "XRP"
  436. counter_currency = "USD"
  437. def __init__(self):
  438. self.orders = []
  439. def get_price(self, symbol):
  440. return {"price": 1.2}
  441. def get_regime(self, symbol, timeframe="1h"):
  442. return {
  443. "trend": {"state": "bull", "ema_fast": 1.21, "ema_slow": 1.18},
  444. "momentum": {"state": "bull", "rsi": 64, "macd_histogram": 0.002},
  445. }
  446. def place_order(self, **kwargs):
  447. self.orders.append(kwargs)
  448. return {"ok": True, "order": kwargs}
  449. def get_account_info(self):
  450. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  451. minimum_order_value = 10.0
  452. def suggest_order_amount(self, **kwargs):
  453. return 10.0
  454. def get_strategy_snapshot(self):
  455. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  456. ctx = FakeContext()
  457. strat = TrendStrategy(ctx, {"order_size": 2.0, "trend_timeframe": "15m"})
  458. result = strat.on_tick({})
  459. assert result["action"] == "buy"
  460. assert ctx.orders[-1]["side"] == "buy"
  461. assert ctx.orders[-1]["amount"] == 10.0
  462. assert strat.state["last_action"] == "buy_trend"
  463. assert strat.state["last_strength"] >= 0.65
  464. def test_trend_follower_sells_from_bear_regime_without_explicit_strength():
  465. class FakeContext:
  466. id = "s-bear"
  467. account_id = "acct-1"
  468. client_id = "cid-1"
  469. mode = "active"
  470. market_symbol = "xrpusd"
  471. base_currency = "XRP"
  472. counter_currency = "USD"
  473. def __init__(self):
  474. self.orders = []
  475. def get_price(self, symbol):
  476. return {"price": 1.2}
  477. def get_regime(self, symbol, timeframe="1h"):
  478. return {
  479. "trend": {"state": "bear", "ema_fast": 1.17, "ema_slow": 1.2},
  480. "momentum": {"state": "bear", "rsi": 36, "macd_histogram": -0.002},
  481. }
  482. def place_order(self, **kwargs):
  483. self.orders.append(kwargs)
  484. return {"ok": True, "order": kwargs}
  485. def get_account_info(self):
  486. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 10}]}
  487. minimum_order_value = 10.0
  488. def suggest_order_amount(self, **kwargs):
  489. return 6.0
  490. def get_strategy_snapshot(self):
  491. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  492. ctx = FakeContext()
  493. strat = TrendStrategy(ctx, {"order_size": 2.0, "trend_timeframe": "15m"})
  494. result = strat.on_tick({})
  495. assert result["action"] == "sell"
  496. assert ctx.orders[-1]["side"] == "sell"
  497. assert ctx.orders[-1]["amount"] == 6.0
  498. assert strat.state["last_action"] == "sell_trend"
  499. assert strat.state["last_strength"] >= 0.65
  500. def test_trend_follower_policy_does_not_override_explicit_order_size():
  501. class FakeContext:
  502. id = "s-explicit"
  503. account_id = "acct-1"
  504. client_id = "cid-1"
  505. mode = "active"
  506. market_symbol = "xrpusd"
  507. base_currency = "XRP"
  508. counter_currency = "USD"
  509. def get_price(self, symbol):
  510. return {"price": 1.2}
  511. def get_regime(self, symbol, timeframe="1h"):
  512. return {"trend": {"state": "bull", "strength": 0.9}}
  513. def get_strategy_snapshot(self):
  514. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  515. strat = TrendStrategy(FakeContext(), {"order_size": 10.5})
  516. strat.apply_policy()
  517. assert strat.config["order_size"] == 10.5
  518. assert strat.state["policy_derived"]["order_size"] == 10.5
  519. def test_trend_follower_passes_live_fee_rate_into_sizing_helper():
  520. class FakeContext:
  521. id = "s-fee"
  522. account_id = "acct-1"
  523. client_id = "cid-1"
  524. mode = "active"
  525. market_symbol = "xrpusd"
  526. base_currency = "XRP"
  527. counter_currency = "USD"
  528. minimum_order_value = 10.0
  529. def __init__(self):
  530. self.fee_calls = []
  531. self.suggest_calls = []
  532. def get_price(self, symbol):
  533. return {"price": 1.2}
  534. def get_regime(self, symbol, timeframe="1h"):
  535. return {"trend": {"state": "bull", "strength": 0.9}}
  536. def get_fee_rates(self, market_symbol=None):
  537. self.fee_calls.append(market_symbol)
  538. return {"maker": 0.0025, "taker": 0.004}
  539. def suggest_order_amount(self, **kwargs):
  540. self.suggest_calls.append(kwargs)
  541. return 8.0
  542. def place_order(self, **kwargs):
  543. return {"ok": True, "order": kwargs}
  544. def get_account_info(self):
  545. return {"balances": [{"asset_code": "USD", "available": 1000}, {"asset_code": "XRP", "available": 0}]}
  546. def get_strategy_snapshot(self):
  547. return {"identity": {}, "control": {}, "position": {}, "orders": {}, "execution": {}}
  548. ctx = FakeContext()
  549. strat = TrendStrategy(ctx, {"order_size": 10.5, "trend_timeframe": "15m"})
  550. strat.on_tick({})
  551. assert ctx.fee_calls == ["xrpusd"]
  552. assert ctx.suggest_calls[-1]["fee_rate"] == 0.0025