Skip to content

Commit b48a632

Browse files
feat(forex): implement all 10 forex trading modules
- FX Pair Registry: 20 currency pairs (major, minor, african, exotic) - Leverage/Margin System: 50:1, 100:1, 200:1 with margin calculations - Swap/Rollover Rates: overnight interest charges per pair - Spread Management: variable spreads with min/typical tracking - FX Order Types: Market, Limit, Stop, OCO, Trailing Stop - Liquidity Provider Integration: 5 demo providers (banks, ECN, prime) - Cross-Rate Calculation: derived rates from major pairs - Regulatory Compliance: Nigeria (CBN), UK (FCA), US (CFTC/NFA), ECOWAS - PWA Forex Trading Page: watchlist, positions, orders, account, pip calc - React Native Forex Screen: mobile-optimized trading with swipe-to-close Co-Authored-By: Patrick Munis <pmunis@gmail.com>
1 parent 94be888 commit b48a632

9 files changed

Lines changed: 2900 additions & 0 deletions

File tree

frontend/mobile/src/screens/ForexScreen.tsx

Lines changed: 535 additions & 0 deletions
Large diffs are not rendered by default.

frontend/pwa/src/app/forex/page.tsx

Lines changed: 1084 additions & 0 deletions
Large diffs are not rendered by default.

frontend/pwa/src/components/layout/Sidebar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
Fingerprint,
2525
Warehouse,
2626
Sprout,
27+
BadgeDollarSign,
2728
type LucideIcon,
2829
} from "lucide-react";
2930

@@ -43,6 +44,7 @@ const navItems: NavItem[] = [
4344
{ href: "/indices", label: "Indices", icon: LineChart },
4445
{ href: "/corporate-actions", label: "Corp Actions", icon: FileText },
4546
{ href: "/brokers", label: "Brokers", icon: Building2 },
47+
{ href: "/forex", label: "Forex Trading", icon: BadgeDollarSign },
4648
{ href: "/digital-assets", label: "Digital Assets", icon: Coins },
4749
{ href: "/onboarding", label: "KYC / KYB", icon: UserCheck },
4850
{ href: "/warehouse-receipts", label: "Warehouse Receipts", icon: Warehouse },

frontend/pwa/src/lib/api-client.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,39 @@ export const api = {
392392
ipfsStatus: () => apiClient.get<APIResponse>("/blockchain/ipfs/status"),
393393
},
394394

395+
// Forex Trading
396+
forex: {
397+
pairs: (category?: string) =>
398+
apiClient.get<APIResponse>("/forex/pairs", category ? { params: { category } } : undefined),
399+
pair: (symbol: string) => apiClient.get<APIResponse>(`/forex/pairs/${encodeURIComponent(symbol)}`),
400+
searchPairs: (query: string) => apiClient.get<APIResponse>("/forex/pairs/search", { params: { q: query } }),
401+
orders: (status?: string) =>
402+
apiClient.get<APIResponse>("/forex/orders", status ? { params: { status } } : undefined),
403+
order: (id: string) => apiClient.get<APIResponse>(`/forex/orders/${id}`),
404+
createOrder: (order: {
405+
pair: string; side: string; type: string; lotSize: number;
406+
price?: number; stopLoss?: number; takeProfit?: number;
407+
trailingStopPips?: number; ocoStopPrice?: number; ocoLimitPrice?: number;
408+
leverage: number; comment?: string;
409+
}) => apiClient.post<APIResponse>("/forex/orders", order),
410+
cancelOrder: (id: string) => apiClient.delete<APIResponse>(`/forex/orders/${id}`),
411+
positions: (status?: string) =>
412+
apiClient.get<APIResponse>("/forex/positions", status ? { params: { status } } : undefined),
413+
position: (id: string) => apiClient.get<APIResponse>(`/forex/positions/${id}`),
414+
modifyPosition: (id: string, data: { stopLoss?: number; takeProfit?: number; trailingStopPips?: number }) =>
415+
apiClient.patch<APIResponse>(`/forex/positions/${id}`, data),
416+
closePosition: (id: string) => apiClient.delete<APIResponse>(`/forex/positions/${id}`),
417+
account: () => apiClient.get<APIResponse>("/forex/account"),
418+
swapRates: () => apiClient.get<APIResponse>("/forex/swap-rates"),
419+
crossRates: () => apiClient.get<APIResponse>("/forex/cross-rates"),
420+
marginRequirements: () => apiClient.get<APIResponse>("/forex/margin-requirements"),
421+
tradingHours: () => apiClient.get<APIResponse>("/forex/trading-hours"),
422+
liquidityProviders: () => apiClient.get<APIResponse>("/forex/liquidity-providers"),
423+
regulatory: () => apiClient.get<APIResponse>("/forex/regulatory"),
424+
pipCalculator: (data: { pair: string; lotSize: number; pips: number }) =>
425+
apiClient.post<APIResponse>("/forex/pip-calculator", data),
426+
},
427+
395428
// Auth
396429
auth: {
397430
login: (credentials: { email: string; password: string }) =>
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/gin-gonic/gin"
7+
"github.com/munisp/NGApp/services/gateway/internal/models"
8+
)
9+
10+
// ============================================================
11+
// Forex Trading API Handlers
12+
// ============================================================
13+
14+
// --- FX Pairs ---
15+
16+
func (s *Server) fxListPairs(c *gin.Context) {
17+
category := c.Query("category")
18+
pairs := s.store.GetFXPairs(category)
19+
c.JSON(http.StatusOK, models.APIResponse{
20+
Success: true,
21+
Data: pairs,
22+
Meta: models.PaginationMeta{Total: len(pairs), Page: 1, Limit: len(pairs), Pages: 1},
23+
})
24+
}
25+
26+
func (s *Server) fxGetPair(c *gin.Context) {
27+
symbol := c.Param("pair")
28+
pair, ok := s.store.GetFXPair(symbol)
29+
if !ok {
30+
c.JSON(http.StatusNotFound, models.APIResponse{Success: false, Error: "FX pair not found: " + symbol})
31+
return
32+
}
33+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: pair})
34+
}
35+
36+
func (s *Server) fxSearchPairs(c *gin.Context) {
37+
q := c.Query("q")
38+
if q == "" {
39+
c.JSON(http.StatusBadRequest, models.APIResponse{Success: false, Error: "query parameter 'q' required"})
40+
return
41+
}
42+
results := s.store.SearchFXPairs(q)
43+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: results})
44+
}
45+
46+
// --- FX Orders ---
47+
48+
func (s *Server) fxListOrders(c *gin.Context) {
49+
userID := s.getUserID(c)
50+
status := c.Query("status")
51+
orders := s.store.GetFXOrders(userID, status)
52+
c.JSON(http.StatusOK, models.APIResponse{
53+
Success: true,
54+
Data: orders,
55+
Meta: models.PaginationMeta{Total: len(orders), Page: 1, Limit: len(orders), Pages: 1},
56+
})
57+
}
58+
59+
func (s *Server) fxGetOrder(c *gin.Context) {
60+
orderID := c.Param("id")
61+
order, ok := s.store.GetFXOrder(orderID)
62+
if !ok {
63+
c.JSON(http.StatusNotFound, models.APIResponse{Success: false, Error: "FX order not found"})
64+
return
65+
}
66+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: order})
67+
}
68+
69+
func (s *Server) fxCreateOrder(c *gin.Context) {
70+
var req models.CreateFXOrderRequest
71+
if err := c.ShouldBindJSON(&req); err != nil {
72+
c.JSON(http.StatusBadRequest, models.APIResponse{Success: false, Error: err.Error()})
73+
return
74+
}
75+
76+
// Validate pair exists
77+
pair, ok := s.store.GetFXPair(req.Pair)
78+
if !ok {
79+
c.JSON(http.StatusBadRequest, models.APIResponse{Success: false, Error: "unknown FX pair: " + req.Pair})
80+
return
81+
}
82+
83+
// Validate lot size
84+
if req.LotSize < pair.MinLotSize || req.LotSize > pair.MaxLotSize {
85+
c.JSON(http.StatusBadRequest, models.APIResponse{
86+
Success: false,
87+
Error: "lot size must be between min and max for this pair",
88+
})
89+
return
90+
}
91+
92+
// Validate leverage
93+
if req.Leverage > pair.MaxLeverage {
94+
c.JSON(http.StatusBadRequest, models.APIResponse{
95+
Success: false,
96+
Error: "leverage exceeds maximum for this pair",
97+
})
98+
return
99+
}
100+
101+
// Validate OCO order has both prices
102+
if req.Type == models.FXOrderOCO && (req.OCOStopPrice == 0 || req.OCOLimitPrice == 0) {
103+
c.JSON(http.StatusBadRequest, models.APIResponse{
104+
Success: false,
105+
Error: "OCO orders require both ocoStopPrice and ocoLimitPrice",
106+
})
107+
return
108+
}
109+
110+
userID := s.getUserID(c)
111+
order := models.FXOrder{
112+
UserID: userID,
113+
Pair: req.Pair,
114+
Side: req.Side,
115+
Type: req.Type,
116+
LotSize: req.LotSize,
117+
Price: req.Price,
118+
StopLoss: req.StopLoss,
119+
TakeProfit: req.TakeProfit,
120+
TrailingStopPips: req.TrailingStopPips,
121+
OCOStopPrice: req.OCOStopPrice,
122+
OCOLimitPrice: req.OCOLimitPrice,
123+
Leverage: req.Leverage,
124+
Comment: req.Comment,
125+
}
126+
127+
created := s.store.CreateFXOrder(order)
128+
129+
// Publish order event via Kafka (fallback: no-op)
130+
s.kafka.ProduceAsync("fx-orders", created.ID, created)
131+
132+
c.JSON(http.StatusCreated, models.APIResponse{Success: true, Data: created})
133+
}
134+
135+
func (s *Server) fxCancelOrder(c *gin.Context) {
136+
orderID := c.Param("id")
137+
order, err := s.store.CancelFXOrder(orderID)
138+
if err != nil {
139+
c.JSON(http.StatusBadRequest, models.APIResponse{Success: false, Error: err.Error()})
140+
return
141+
}
142+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: order})
143+
}
144+
145+
// --- FX Positions ---
146+
147+
func (s *Server) fxListPositions(c *gin.Context) {
148+
userID := s.getUserID(c)
149+
status := c.DefaultQuery("status", "OPEN")
150+
positions := s.store.GetFXPositions(userID, status)
151+
c.JSON(http.StatusOK, models.APIResponse{
152+
Success: true,
153+
Data: positions,
154+
Meta: models.PaginationMeta{Total: len(positions), Page: 1, Limit: len(positions), Pages: 1},
155+
})
156+
}
157+
158+
func (s *Server) fxGetPosition(c *gin.Context) {
159+
posID := c.Param("id")
160+
pos, ok := s.store.GetFXPosition(posID)
161+
if !ok {
162+
c.JSON(http.StatusNotFound, models.APIResponse{Success: false, Error: "FX position not found"})
163+
return
164+
}
165+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: pos})
166+
}
167+
168+
func (s *Server) fxModifyPosition(c *gin.Context) {
169+
posID := c.Param("id")
170+
var req models.ModifyFXPositionRequest
171+
if err := c.ShouldBindJSON(&req); err != nil {
172+
c.JSON(http.StatusBadRequest, models.APIResponse{Success: false, Error: err.Error()})
173+
return
174+
}
175+
pos, err := s.store.ModifyFXPosition(posID, req)
176+
if err != nil {
177+
c.JSON(http.StatusBadRequest, models.APIResponse{Success: false, Error: err.Error()})
178+
return
179+
}
180+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: pos})
181+
}
182+
183+
func (s *Server) fxClosePosition(c *gin.Context) {
184+
posID := c.Param("id")
185+
pos, err := s.store.CloseFXPosition(posID)
186+
if err != nil {
187+
c.JSON(http.StatusBadRequest, models.APIResponse{Success: false, Error: err.Error()})
188+
return
189+
}
190+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: pos})
191+
}
192+
193+
// --- FX Account ---
194+
195+
func (s *Server) fxAccountSummary(c *gin.Context) {
196+
userID := s.getUserID(c)
197+
summary := s.store.GetFXAccountSummary(userID)
198+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: summary})
199+
}
200+
201+
// --- FX Swap Rates ---
202+
203+
func (s *Server) fxSwapRates(c *gin.Context) {
204+
rates := s.store.GetFXSwapRates()
205+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: rates})
206+
}
207+
208+
// --- FX Cross Rates ---
209+
210+
func (s *Server) fxCrossRates(c *gin.Context) {
211+
rates := s.store.GetFXCrossRates()
212+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: rates})
213+
}
214+
215+
// --- FX Margin Requirements ---
216+
217+
func (s *Server) fxMarginRequirements(c *gin.Context) {
218+
reqs := s.store.GetFXMarginRequirements()
219+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: reqs})
220+
}
221+
222+
// --- FX Liquidity Providers ---
223+
224+
func (s *Server) fxLiquidityProviders(c *gin.Context) {
225+
providers := s.store.GetFXLiquidityProviders()
226+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: providers})
227+
}
228+
229+
// --- FX Regulatory Info ---
230+
231+
func (s *Server) fxRegulatoryInfo(c *gin.Context) {
232+
info := s.store.GetFXRegulatoryInfo()
233+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: info})
234+
}
235+
236+
// --- FX Pip Calculator ---
237+
238+
func (s *Server) fxPipCalculator(c *gin.Context) {
239+
var req models.FXPipCalculatorRequest
240+
if err := c.ShouldBindJSON(&req); err != nil {
241+
c.JSON(http.StatusBadRequest, models.APIResponse{Success: false, Error: err.Error()})
242+
return
243+
}
244+
result := s.store.CalculateFXPips(req)
245+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: result})
246+
}
247+
248+
// --- FX Trading Hours ---
249+
250+
func (s *Server) fxTradingHours(c *gin.Context) {
251+
pairs := s.store.GetFXPairs("")
252+
type hourInfo struct {
253+
Pair string `json:"pair"`
254+
TradingHours string `json:"tradingHours"`
255+
Active bool `json:"active"`
256+
Category string `json:"category"`
257+
}
258+
var hours []hourInfo
259+
for _, p := range pairs {
260+
hours = append(hours, hourInfo{
261+
Pair: p.Symbol,
262+
TradingHours: p.TradingHours,
263+
Active: p.Active,
264+
Category: p.Category,
265+
})
266+
}
267+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: hours})
268+
}

services/gateway/internal/api/server.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,44 @@ func (s *Server) SetupRoutes() *gin.Engine {
313313
protected.GET("/produce/inventory", s.permifyGuard("commodity", "view"), s.kycProduceInventory)
314314
protected.POST("/produce/register", s.permifyGuard("commodity", "trade"), s.kycRegisterProduce)
315315

316+
// Forex Trading routes — Permify: fx_pair trade permission
317+
fx := protected.Group("/forex")
318+
fx.Use(s.permifyMiddleware("fx_pair", "trade"))
319+
{
320+
// FX Pairs
321+
fx.GET("/pairs", s.fxListPairs)
322+
fx.GET("/pairs/search", s.fxSearchPairs)
323+
fx.GET("/pairs/:pair", s.fxGetPair)
324+
325+
// FX Orders
326+
fx.GET("/orders", s.fxListOrders)
327+
fx.POST("/orders", s.fxCreateOrder)
328+
fx.GET("/orders/:id", s.fxGetOrder)
329+
fx.DELETE("/orders/:id", s.fxCancelOrder)
330+
331+
// FX Positions
332+
fx.GET("/positions", s.fxListPositions)
333+
fx.GET("/positions/:id", s.fxGetPosition)
334+
fx.PATCH("/positions/:id", s.fxModifyPosition)
335+
fx.DELETE("/positions/:id", s.fxClosePosition)
336+
337+
// FX Account
338+
fx.GET("/account", s.fxAccountSummary)
339+
340+
// FX Market Data
341+
fx.GET("/swap-rates", s.fxSwapRates)
342+
fx.GET("/cross-rates", s.fxCrossRates)
343+
fx.GET("/margin-requirements", s.fxMarginRequirements)
344+
fx.GET("/trading-hours", s.fxTradingHours)
345+
346+
// FX Infrastructure
347+
fx.GET("/liquidity-providers", s.fxLiquidityProviders)
348+
fx.GET("/regulatory", s.fxRegulatoryInfo)
349+
350+
// FX Tools
351+
fx.POST("/pip-calculator", s.fxPipCalculator)
352+
}
353+
316354
// WebSocket endpoint for real-time notifications — Permify: user access
317355
protected.GET("/ws/notifications", s.permifyGuard("user", "access"), s.wsNotifications)
318356
protected.GET("/ws/market-data", s.permifyGuard("commodity", "view"), s.wsMarketData)

0 commit comments

Comments
 (0)