[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:
Kunthawat Greethong
2026-08-25 16:24:22 +07:00
parent 2caf814e21
commit c3a1461932
6 changed files with 529 additions and 0 deletions

View File

@@ -482,6 +482,103 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
) )
@app.route("/api/v1/simulation", methods=["POST"])
def simulation():
"""Capital-allocation simulation (paper/backtest; never a real order).
Body: {capital: float, mode: "backtest"|"forward"}.
Combines per-symbol combined score (themes 60/40) with dividend status
(Siamchart) and latest price (Yahoo snapshot), then allocates across
50/20/30 buckets. Output is labeled paper/backtest on revised history
(non-PIT) — not validated evidence.
"""
from app import simulation as sim
from app import siamchart_factors
payload = request.get_json(silent=True) or {}
try:
capital = float(payload.get("capital", 0))
except (TypeError, ValueError):
return jsonify({"error": "capital must be a number"}), 400
mode = payload.get("mode", "backtest") in ("backtest", "forward") and payload.get("mode", "backtest")
if capital <= 0:
return jsonify({"error": "capital must be > 0"}), 400
try:
series = sim.load_price_snapshot()
prices = sim.latest_prices(series)
except (sim.SimulationError, OSError) as exc:
return jsonify({"error": f"price snapshot: {exc}"}), 503
# combined score from the multi-theme board
factor_view = siamchart_factors.build_factor_view()
# recompute theme+combined by importing the same scoring path
from app import themes as themes_mod
from app import auto_credit, daily_cache, energy_thai
current = app.extensions["tourism_result"]
cache = app.extensions.setdefault("daily_cache", daily_cache.DailyCache())
tourism_scores = themes_mod.build_theme_scores("tourism", current.get("signals", []))
try:
auto_d = cache.fetch_or_stale(
f"auto_credit/{current.get('as_of','')}",
lambda: auto_credit.fetch_auto_credit().to_dict(),
)
auto_sign = 1 if (auto_d.get("new_car_sales_yoy") or 0) > 0 else -1
except Exception:
auto_sign = 0
try:
en_d = cache.fetch_or_stale(
"energy_thai", lambda: energy_thai.fetch_energy_thai().to_dict())
qmap = en_d.get("quarterly", {})
latest = next(iter(qmap.values()), {})
en_sign = 1 if (latest.get("net_profit") or 0) > 0 else -1
except Exception:
en_sign = 0
theme_scores = {
"tourism": tourism_scores,
"auto_credit": {s: auto_sign for s in themes_mod.THEME_SYMBOLS["auto_credit"]},
"refining_energy": {s: en_sign for s in themes_mod.THEME_SYMBOLS["refining_energy"]},
}
siamchart_score = themes_mod.build_siamchart_score(factor_view)
combined = themes_mod.combine_score(
[theme_scores["tourism"], theme_scores["auto_credit"], theme_scores["refining_energy"]],
siamchart_score,
)
# factors for dividend status + yield
factor_by_symbol = {f["symbol"]: f for f in factor_view.get("factors", [])}
candidates = []
for sym, meta in combined.items():
price = prices.get(sym)
if price is None:
continue
f = factor_by_symbol.get(sym, {})
candidates.append(
sim.Candidate(
symbol=sym,
price=price,
combined_score=meta["combined"],
is_dividend=bool(f.get("is_dividend")),
dividend_yield=float(f.get("dividend_yield") or 0.0),
)
)
try:
result = sim.allocate_capital(capital, candidates)
except sim.SimulationError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(
{
"mode": mode,
"capital": capital,
"as_of": current.get("as_of"),
"data_note": "paper/backtest on revised vendor history (non-PIT) — not validated evidence",
**result.to_dict(),
}
)
@app.get("/api/v1/themes") @app.get("/api/v1/themes")
def themes(): def themes():
"""Multi-theme combined board. """Multi-theme combined board.

109
backend/app/mt5_bridge.py Normal file
View File

@@ -0,0 +1,109 @@
"""MT5 order bridge — interface only; LIVE dispatch is strictly gated off.
The forward test shares the backtest engine and may *optionally* dispatch orders
to MetaTrader 5. This module provides the bridge *interface* with a structural
kill switch: no real order path exists in code unless BOTH of these hold:
1. the environment flag MT5_ENABLE_ORDER=1, AND
2. explicit per-run approval is passed.
`MetaTrader5` is a Windows-only third-party module; it is imported lazily and
guarded so the rest of the platform (and tests) run fine without it. On non-
Windows (or when not installed) the bridge reports `unavailable` rather than
failing the platform.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Optional
class MT5UnavailableError(Exception):
"""Raised when MetaTrader5 is not available / connected."""
@dataclass
class MT5OrderRequest:
symbol: str
action: str # BUY / SELL
lots: float
price: Optional[float] = None
sl: Optional[float] = None
tp: Optional[float] = None
@dataclass
class MT5OrderResult:
symbol: str
ticket: int
executed: bool
dry_run: bool
message: str = ""
def _mt5():
"""Return the MetaTrader5 module or None (Windows-only + installed)."""
try:
import MetaTrader5 as mt5 # type: ignore
return mt5
except ImportError:
return None
def is_available() -> bool:
return _mt5() is not None
def enabled() -> bool:
"""Live dispatch is enabled only when MT5_SEND_ORDERS=1 is set explicitly."""
return os.environ.get("MT5_SEND_ORDERS", "").strip() == "1"
def place_order(req: MT5OrderRequest, approve: bool = False) -> MT5OrderResult:
"""Place an order.
- Without `enabled()` (flag) or explicit approval, this is a DRY-RUN: no
order is sent; we return an order-shaped result with executed=False.
- The caller (simulation forward mode) decides whether to actually call the
live path; default is dry-run. This module never self-authorizes.
"""
if not (enabled() and approve):
return MT5OrderResult(req.symbol, 0, False, dry_run=True,
message="live dispatch gated: MT5_SEND_ORDERS=1 AND approval required")
mt5 = _mt5()
if mt5 is None:
return MT5OrderResult(req.symbol, 0, False, dry_run=True,
message="MetaTrader5 not available (Windows-only module)")
if not mt5.initialize():
raise MT5UnavailableError(mt5.last_error())
symbol_info = mt5.symbol_info(req.symbol)
if symbol_info is None:
raise MT5UnavailableError(f"unknown symbol {req.symbol}")
request = {
"action": mt5.TRADE_ACTION_DEAL,
"symbol": req.symbol,
"volume": req.lots,
"type": mt5.ORDER_TYPE_BUY if req.action == "BUY" else mt5.ORDER_TYPE_SELL,
"price": req.price or symbol_info.ask if req.action == "BUY" else req.price or symbol_info.bid,
"sl": req.sl,
"tp": req.tp,
"deviation": 20,
"magic": 567890,
"comment": "SET50 alternative-data sim",
"type_time": mt5.ORDER_TIME_GTC,
"type_filling": mt5.ORDER_FILLING_IOC,
}
result = mt5.order_send(request)
return MT5OrderResult(
req.symbol,
ticket=result.order if result else 0,
executed=bool(result and result.retcode == mt5.TRADE_RETCODE_DONE),
dry_run=False,
message=str(result),
)

171
backend/app/simulation.py Normal file
View File

@@ -0,0 +1,171 @@
"""Capital-allocation simulation/backtest engine.
Reusable for both backtest (historical) and forward test (paper, no MT5 send) —
the user asked these share one engine, differing only in whether an MT5 order is
dispatched. Pure local research; never sends a real order.
Allocation rules (confirmed by the user, 2026-08-25):
Bucket 1 (50% of capital) : highest "profit-opportunity" score that pays a dividend
Bucket 2 (20% of capital) : highest "profit-opportunity" score that does NOT pay a dividend
Bucket 3 (30% of capital) : highest dividend yield among names NOT already bought in bucket 1 (ignores score)
Per symbol a minimum of 100 shares; rank candidates by combined score descending.
If a bucket's first pick can't afford 100 shares, try progressively cheaper eligible
names; if none fits, leave the remainder as cash.
The engine is honest about data provenance: it runs on *revised vendor history*
(non-PIT), so output must be labeled paper/backtest, never validated PIT evidence.
"""
from __future__ import annotations
import json
import math
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
_PRICES_DIR = Path(__file__).resolve().parent.parent / "data" / "prices"
MIN_SHARES = 100
class SimulationError(Exception):
"""Raised for invalid capital / allocation inputs."""
@dataclass
class Candidate:
symbol: str
price: float
combined_score: float
is_dividend: bool
dividend_yield: float
@dataclass
class Order:
symbol: str
bucket: int
qty: int
price: float
notional: float
@dataclass
class AllocationResult:
capital: float
bucket_allocation: dict = field(default_factory=dict) # {1: amount, 2:..., 3:...}
orders: list = field(default_factory=list)
invested: float = 0.0
unallocated_cash: float = 0.0
bucket_notional: dict = field(default_factory=dict) # {1: notional, ...}
def to_dict(self) -> dict:
return {
"capital": self.capital,
"buckets": self.bucket_allocation,
"orders": [o.__dict__ for o in self.orders],
"invested": round(self.invested, 2),
"unallocated_cash": round(self.unallocated_cash, 2),
"bucket_notional": self.bucket_notional,
}
# ---------------------------------------------------------------------------
# B1: price-series loader
# ---------------------------------------------------------------------------
def load_price_snapshot(snapshot_path: Optional[Path] = None) -> dict:
"""Load the newest Yahoo price snapshot: {symbol: {bars: [...]}}."""
if snapshot_path is None:
snap_dir = _PRICES_DIR / "snapshots"
files = sorted(snap_dir.glob("prices-yahoo-chart-*.json"))
if not files:
raise SimulationError("no Yahoo price snapshot found on disk")
snapshot_path = files[-1]
data = json.loads(snapshot_path.read_text(encoding="utf-8"))
return data.get("series", {})
def latest_prices(series: dict) -> dict[str, float]:
"""Latest adjusted_close per symbol from the price series."""
out: dict[str, float] = {}
for sym, s in series.items():
bars = s.get("bars", [])
if bars:
out[sym] = float(bars[-1]["adjusted_close"])
return out
# ---------------------------------------------------------------------------
# B2: capital allocation core
# ---------------------------------------------------------------------------
def allocate_capital(
capital: float,
candidates: list[Candidate],
bucket_b1: float = 0.50,
bucket_b2: float = 0.20,
bucket_b3: float = 0.30,
) -> AllocationResult:
"""Allocate `capital` across the three dividend/profit buckets."""
if capital <= 0:
raise SimulationError("capital must be > 0")
if not candidates:
raise SimulationError("no candidates to allocate")
# sort all by combined_score desc (used for bucket 1 & 2 ranking)
by_score = sorted(candidates, key=lambda c: -c.combined_score)
# bucket 3 ranked by dividend yield desc among dividend payers
by_yield = sorted(
(c for c in candidates if c.is_dividend and c.dividend_yield > 0),
key=lambda c: -c.dividend_yield,
)
b1_amount = capital * bucket_b1
b2_amount = capital * bucket_b2
b3_amount = capital * bucket_b3
result = AllocationResult(
capital=capital,
bucket_allocation={1: b1_amount, 2: b2_amount, 3: b3_amount},
)
cash = [b1_amount, b2_amount, b3_amount] # per-bucket remaining
used = set()
def _fill(bucket_idx: int, eligible: list[Candidate], require_dividend: bool):
nonlocal cash, used
remaining = cash[bucket_idx]
for cand in sorted(eligible, key=lambda c: -c.combined_score):
if cand.symbol in used:
continue
if require_dividend and not cand.is_dividend:
continue
if cand.price <= 0:
continue
# max shares affordable within this bucket, floor to 100-share lots
max_qty = int(remaining // cand.price)
qty = (max_qty // MIN_SHARES) * MIN_SHARES
if qty < MIN_SHARES:
continue # can't afford minimum; try cheaper name
notional = qty * cand.price
result.orders.append(
Order(cand.symbol, bucket_idx + 1, qty, cand.price, notional)
)
remaining -= notional
used.add(cand.symbol)
result.invested += notional
cash[bucket_idx] = remaining
result.bucket_notional[bucket_idx + 1] = b_st = (
result.bucket_allocation[bucket_idx + 1] - remaining
)
# Bucket 1: dividend-paying, highest score
_fill(0, [c for c in by_score if c.is_dividend], require_dividend=True)
# Bucket 2: non-dividend, highest score
_fill(1, [c for c in by_score if not c.is_dividend], require_dividend=False)
# Bucket 3: highest dividend yield, excluding symbols already bought
_fill(2, by_yield, require_dividend=True)
result.unallocated_cash = sum(cash)
return result

View File

@@ -67,6 +67,38 @@ class ApiTests(unittest.TestCase):
self.assertGreaterEqual(payload["combined_count"], 1) self.assertGreaterEqual(payload["combined_count"], 1)
self.assertIsInstance(payload["board"], list) 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): def test_health_reports_research_mode(self):
response = self.client.get("/api/v1/health") response = self.client.get("/api/v1/health")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)

View 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()

View 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()