[verified] Add capital-allocation simulation engine + MT5 bridge (both dry-run/gated)
- simulation.py: price-series loader (Yahoo snapshot) + allocate_capital 50/20/30 with min-100 shares, bucket3 excludes bucket1, cash fallback - /api/v1/simulation POST: combines theme 60/40 score + Siamchart dividend + Yahoo price; labels output paper/backtest non-PIT (never validated) - mt5_bridge.py: MT5 order interface, dry-run default; live dispatch needs MT5_SEND_ORDERS=1 AND approval (Windows-only MetaTrader5) - 12 new tests (simulation/mt5/api); full suite 184 OK; live verified (1M -> buckets)
This commit is contained in:
@@ -67,6 +67,38 @@ class ApiTests(unittest.TestCase):
|
||||
self.assertGreaterEqual(payload["combined_count"], 1)
|
||||
self.assertIsInstance(payload["board"], list)
|
||||
|
||||
def test_simulation_allocates_capital(self):
|
||||
from unittest.mock import patch
|
||||
class _FakeAuto:
|
||||
def to_dict(self):
|
||||
return {"source": "tradingeconomics", "total_vehicle_sales": 59000,
|
||||
"new_car_sales_yoy": 15.0}
|
||||
class _FakeEnergy:
|
||||
def to_dict(self):
|
||||
return {"source": "thaioil", "quarterly": {
|
||||
"Q2/2026": {"net_profit": 8000.0, "ebitda": 9000.0, "sales": 120000.0}}}
|
||||
with patch("app.auto_credit.fetch_auto_credit", return_value=_FakeAuto()), \
|
||||
patch("app.energy_thai.fetch_energy_thai", return_value=_FakeEnergy()):
|
||||
resp = self.client.post("/api/v1/simulation", json={"capital": 500000, "mode": "backtest"})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
payload = resp.get_json()
|
||||
self.assertEqual(payload["capital"], 500000)
|
||||
self.assertIn("orders", payload)
|
||||
self.assertIn("unallocated_cash", payload)
|
||||
# invested + unallocated == capital
|
||||
self.assertAlmostEqual(payload["invested"] + payload["unallocated_cash"], 500000, places=2)
|
||||
# orders are 100-share lots
|
||||
for order in payload["orders"]:
|
||||
self.assertEqual(order["qty"] % 100, 0)
|
||||
|
||||
def test_simulation_rejects_invalid_capital(self):
|
||||
resp = self.client.post("/api/v1/simulation", json={"capital": 0})
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
resp2 = self.client.post("/api/v1/simulation", json={"capital": -5})
|
||||
self.assertEqual(resp2.status_code, 400)
|
||||
resp3 = self.client.post("/api/v1/simulation", json={"capital": "abc"})
|
||||
self.assertEqual(resp3.status_code, 400)
|
||||
|
||||
def test_health_reports_research_mode(self):
|
||||
response = self.client.get("/api/v1/health")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
48
backend/tests/test_mt5_bridge.py
Normal file
48
backend/tests/test_mt5_bridge.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Tests for the MT5 bridge (dry-run default + live gating)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from app import mt5_bridge
|
||||
from app.mt5_bridge import MT5OrderRequest, place_order
|
||||
|
||||
|
||||
class MT5BridgeTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# ensure live dispatch is OFF during these tests
|
||||
self._prev = os.environ.get("MT5_SEND_ORDERS")
|
||||
os.environ.pop("MT5_SEND_ORDERS", None)
|
||||
|
||||
def tearDown(self):
|
||||
if self._prev is None:
|
||||
os.environ.pop("MT5_SEND_ORDERS", None)
|
||||
else:
|
||||
os.environ["MT5_SEND_ORDERS"] = self._prev
|
||||
|
||||
def test_place_order_dry_runs_without_flag(self):
|
||||
res = place_order(MT5OrderRequest("AOT", "BUY", 1.0), approve=True)
|
||||
# Even with approval, without the env flag it stays dry-run.
|
||||
self.assertFalse(res.executed)
|
||||
self.assertTrue(res.dry_run)
|
||||
|
||||
def test_disabled_without_approval(self):
|
||||
res = place_order(MT5OrderRequest("AOT", "BUY", 1.0), approve=False)
|
||||
self.assertFalse(res.executed)
|
||||
self.assertTrue(res.dry_run)
|
||||
self.assertIn("gated", res.message)
|
||||
|
||||
def test_enabled_requires_both(self):
|
||||
os.environ["MT5_SEND_ORDERS"] = "1"
|
||||
# approval required even when flag set; but module likely unavailable ->
|
||||
# still dry-run with 'not available' (never sends real order in tests)
|
||||
res = place_order(MT5OrderRequest("AOT", "BUY", 1.0), approve=False)
|
||||
self.assertFalse(res.executed)
|
||||
|
||||
def test_is_available_is_bool(self):
|
||||
self.assertIsInstance(mt5_bridge.is_available(), bool)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
72
backend/tests/test_simulation.py
Normal file
72
backend/tests/test_simulation.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Tests for the capital-allocation simulation engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.simulation import Candidate, allocate_capital
|
||||
|
||||
|
||||
class AllocationTest(unittest.TestCase):
|
||||
def make_candidates(self):
|
||||
# dividend + high score
|
||||
return [
|
||||
Candidate("A", 10.0, 1.5, True, 5.0),
|
||||
Candidate("B", 20.0, 1.2, True, 3.0),
|
||||
Candidate("C", 5.0, 1.0, False, 0.0),
|
||||
Candidate("D", 2.0, 0.5, False, 0.0),
|
||||
Candidate("E", 50.0, 0.9, True, 8.0),
|
||||
]
|
||||
|
||||
def test_allocates_three_buckets(self):
|
||||
res = allocate_capital(200_000, self.make_candidates())
|
||||
bucket_notional = res.bucket_notional
|
||||
self.assertIn(1, bucket_notional)
|
||||
self.assertIn(2, bucket_notional)
|
||||
self.assertIn(3, bucket_notional)
|
||||
# total invested + unallocated == capital
|
||||
self.assertAlmostEqual(res.invested + res.unallocated_cash, 200_000, places=2)
|
||||
# every order is a multiple of 100
|
||||
for o in res.orders:
|
||||
self.assertEqual(o.qty % 100, 0)
|
||||
self.assertGreaterEqual(o.qty, 100)
|
||||
|
||||
def test_min_100_shares_respected(self):
|
||||
# tiny capital: bucket1 50% still must afford 100 shares
|
||||
res = allocate_capital(2_000, self.make_candidates())
|
||||
for o in res.orders:
|
||||
self.assertGreaterEqual(o.qty, 100)
|
||||
self.assertEqual(o.notional, o.qty * o.price)
|
||||
# never over-invest beyond capital
|
||||
self.assertLessEqual(res.invested, 2_000)
|
||||
|
||||
def test_bucket3_excludes_already_bought(self):
|
||||
cands = [Candidate("A", 10.0, 1.5, True, 5.0),
|
||||
Candidate("B", 10.0, 0.1, True, 8.0)]
|
||||
res = allocate_capital(300_000, cands)
|
||||
# A (bought in bucket1) must NOT also appear in bucket3
|
||||
bucket3_syms = [o.symbol for o in res.orders if o.bucket == 3]
|
||||
bucket1_syms = [o.symbol for o in res.orders if o.bucket == 1]
|
||||
overlap = set(bucket3_syms) & set(bucket1_syms)
|
||||
self.assertEqual(overlap, set())
|
||||
|
||||
def test_invalid_capital_raises(self):
|
||||
with self.assertRaises(Exception):
|
||||
allocate_capital(0, self.make_candidates())
|
||||
with self.assertRaises(Exception):
|
||||
allocate_capital(-100, self.make_candidates())
|
||||
|
||||
def test_no_candidates_raises(self):
|
||||
with self.assertRaises(Exception):
|
||||
allocate_capital(100_000, [])
|
||||
|
||||
def test_cash_fallback_when_nothing_fits(self):
|
||||
# only very high-priced names, tiny capital -> cannot buy 100 shares -> cash
|
||||
cands = [Candidate("X", 1000.0, 2.0, True, 3.0)]
|
||||
res = allocate_capital(50_000, cands) # 50% = 25k, can't buy 100*1000
|
||||
self.assertEqual(res.orders, [])
|
||||
self.assertAlmostEqual(res.unallocated_cash, 50_000, places=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user