Skip to content

Commit 4de32fc

Browse files
feat(market-data): integrate OANDA, Polygon.io, IEX Cloud, Economic Calendar external data sources
- Add OANDA v20 REST API client for real-time FX price feeds (bid/ask, candles, instruments) - Add Polygon.io API client for US equities/NYSE data (snapshots, aggregates, ticker details, exchanges) - Add IEX Cloud API client for reference data (quotes, company info, dividends, earnings, key stats) - Add Economic Calendar client for central bank rates, economic events, swap rates, exchange rates - Add unified market data aggregator client coordinating all 4 providers - Add 19 market data API endpoints under /api/v1/market-data - Add PWA Market Data Sources page with provider status, central bank rates, economic calendar, FX rates - Wire config, server, main.go, tests for new market data clients - All clients support fallback mode, circuit breaker, reconnection loop, metrics tracking Co-Authored-By: Patrick Munis <pmunis@gmail.com>
1 parent bb6f11e commit 4de32fc

14 files changed

Lines changed: 2782 additions & 2 deletions

File tree

frontend/pwa/src/app/market-data/page.tsx

Lines changed: 550 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
@@ -25,6 +25,7 @@ import {
2525
Warehouse,
2626
Sprout,
2727
BadgeDollarSign,
28+
Database,
2829
type LucideIcon,
2930
} from "lucide-react";
3031

@@ -45,6 +46,7 @@ const navItems: NavItem[] = [
4546
{ href: "/corporate-actions", label: "Corp Actions", icon: FileText },
4647
{ href: "/brokers", label: "Brokers", icon: Building2 },
4748
{ href: "/forex", label: "Forex Trading", icon: BadgeDollarSign },
49+
{ href: "/market-data", label: "Market Data", icon: Database },
4850
{ href: "/digital-assets", label: "Digital Assets", icon: Coins },
4951
{ href: "/onboarding", label: "KYC / KYB", icon: UserCheck },
5052
{ href: "/warehouse-receipts", label: "Warehouse Receipts", icon: Warehouse },

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,44 @@ export const api = {
425425
apiClient.post<APIResponse>("/forex/pip-calculator", data),
426426
},
427427

428+
// External Market Data Sources
429+
marketData: {
430+
status: () => apiClient.get<APIResponse>("/market-data/status"),
431+
// OANDA FX
432+
fxPrices: (instruments?: string) =>
433+
apiClient.get<APIResponse>("/market-data/fx/prices", instruments ? { params: { instruments } } : undefined),
434+
fxCandles: (instrument: string, granularity?: string, count?: number) =>
435+
apiClient.get<APIResponse>(`/market-data/fx/candles/${instrument}`, {
436+
params: { granularity: granularity || "H1", count: String(count || 100) },
437+
}),
438+
fxInstruments: () => apiClient.get<APIResponse>("/market-data/fx/instruments"),
439+
// Polygon.io Equities
440+
equitySnapshot: (ticker: string) => apiClient.get<APIResponse>(`/market-data/equities/snapshot/${ticker}`),
441+
equityAggregates: (ticker: string, params: { multiplier?: number; timespan?: string; from: string; to: string }) =>
442+
apiClient.get<APIResponse>(`/market-data/equities/aggregates/${ticker}`, {
443+
params: { multiplier: String(params.multiplier || 1), timespan: params.timespan || "day", from: params.from, to: params.to },
444+
}),
445+
equityDetails: (ticker: string) => apiClient.get<APIResponse>(`/market-data/equities/details/${ticker}`),
446+
equitySearch: (query: string, market?: string) =>
447+
apiClient.get<APIResponse>("/market-data/equities/search", { params: { q: query, market: market || "stocks" } }),
448+
equityExchanges: () => apiClient.get<APIResponse>("/market-data/equities/exchanges"),
449+
equityMarketStatus: () => apiClient.get<APIResponse>("/market-data/equities/market-status"),
450+
// IEX Cloud Reference
451+
refQuote: (symbol: string) => apiClient.get<APIResponse>(`/market-data/reference/quote/${symbol}`),
452+
refCompany: (symbol: string) => apiClient.get<APIResponse>(`/market-data/reference/company/${symbol}`),
453+
refDividends: (symbol: string, range?: string) =>
454+
apiClient.get<APIResponse>(`/market-data/reference/dividends/${symbol}`, range ? { params: { range } } : undefined),
455+
refEarnings: (symbol: string, last?: number) =>
456+
apiClient.get<APIResponse>(`/market-data/reference/earnings/${symbol}`, last ? { params: { last: String(last) } } : undefined),
457+
refStats: (symbol: string) => apiClient.get<APIResponse>(`/market-data/reference/stats/${symbol}`),
458+
// Economic Calendar
459+
centralBankRates: () => apiClient.get<APIResponse>("/market-data/calendar/central-bank-rates"),
460+
economicEvents: (currency?: string) =>
461+
apiClient.get<APIResponse>("/market-data/calendar/events", currency ? { params: { currency } } : undefined),
462+
swapRates: () => apiClient.get<APIResponse>("/market-data/calendar/swap-rates"),
463+
exchangeRates: () => apiClient.get<APIResponse>("/market-data/calendar/exchange-rates"),
464+
},
465+
428466
// Auth
429467
auth: {
430468
login: (credentials: { email: string; password: string }) =>

services/gateway/cmd/main.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/munisp/NGApp/services/gateway/internal/fluvio"
1717
kafkaclient "github.com/munisp/NGApp/services/gateway/internal/kafka"
1818
"github.com/munisp/NGApp/services/gateway/internal/keycloak"
19+
"github.com/munisp/NGApp/services/gateway/internal/marketdata"
1920
"github.com/munisp/NGApp/services/gateway/internal/permify"
2021
redisclient "github.com/munisp/NGApp/services/gateway/internal/redis"
2122
"github.com/munisp/NGApp/services/gateway/internal/temporal"
@@ -39,6 +40,16 @@ func main() {
3940
// Wire OpenAppSec WAF as APISIX ext-plugin on primary route
4041
apisixClient.ConfigureOpenAppSecPlugin("gateway-primary", cfg.OpenAppSecURL)
4142

43+
// Initialize external market data clients (OANDA, Polygon, IEX, Calendar)
44+
marketDataClient := marketdata.NewClient(marketdata.Config{
45+
OandaBaseURL: cfg.OandaBaseURL,
46+
OandaAPIKey: cfg.OandaAPIKey,
47+
OandaAccountID: cfg.OandaAccountID,
48+
PolygonAPIKey: cfg.PolygonAPIKey,
49+
IEXAPIKey: cfg.IEXAPIKey,
50+
FREDAPIKey: cfg.FREDAPIKey,
51+
})
52+
4253
// Create API server with all dependencies
4354
server := api.NewServer(
4455
cfg,
@@ -51,6 +62,7 @@ func main() {
5162
keycloakClient,
5263
permifyClient,
5364
apisixClient,
65+
marketDataClient,
5466
)
5567

5668
// Setup routes
@@ -92,6 +104,7 @@ func main() {
92104
daprClient.Close()
93105
fluvioClient.Close()
94106
apisixClient.Close()
107+
marketDataClient.Close()
95108

96109
log.Println("Server exited cleanly")
97110
}

services/gateway/internal/api/integration_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/munisp/NGApp/services/gateway/internal/fluvio"
1616
kafkaclient "github.com/munisp/NGApp/services/gateway/internal/kafka"
1717
"github.com/munisp/NGApp/services/gateway/internal/keycloak"
18+
"github.com/munisp/NGApp/services/gateway/internal/marketdata"
1819
"github.com/munisp/NGApp/services/gateway/internal/permify"
1920
redisclient "github.com/munisp/NGApp/services/gateway/internal/redis"
2021
"github.com/munisp/NGApp/services/gateway/internal/temporal"
@@ -57,8 +58,9 @@ func setupIntegrationServer() *gin.Engine {
5758
kc := keycloak.NewClient(cfg.KeycloakURL, cfg.KeycloakRealm, cfg.KeycloakClientID)
5859
p := permify.NewClient(cfg.PermifyEndpoint)
5960
a := apisix.NewClient(cfg.APISIXAdminURL, cfg.APISIXAdminKey)
61+
md := marketdata.NewClient(marketdata.Config{})
6062

61-
srv := NewServer(cfg, k, r, t, tb, d, f, kc, p, a)
63+
srv := NewServer(cfg, k, r, t, tb, d, f, kc, p, a, md)
6264
return srv.SetupRoutes()
6365
}
6466

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
"strconv"
6+
7+
"github.com/gin-gonic/gin"
8+
"github.com/munisp/NGApp/services/gateway/internal/models"
9+
)
10+
11+
// ============================================================
12+
// External Market Data API Handlers
13+
// ============================================================
14+
// Exposes OANDA, Polygon.io, IEX Cloud, and Economic Calendar
15+
// data through the gateway REST API.
16+
17+
// --- Market Data Sources Status ---
18+
19+
func (s *Server) marketDataStatus(c *gin.Context) {
20+
status := s.marketData.Status()
21+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: status})
22+
}
23+
24+
// --- OANDA FX Price Feed ---
25+
26+
func (s *Server) oandaPrices(c *gin.Context) {
27+
instruments := c.Query("instruments")
28+
if instruments == "" {
29+
instruments = "EUR_USD,GBP_USD,USD_JPY,USD_CHF,AUD_USD,USD_CAD,NZD_USD,EUR_GBP,EUR_JPY,GBP_JPY"
30+
}
31+
prices, err := s.marketData.Oanda.GetPrices(instruments)
32+
if err != nil {
33+
c.JSON(http.StatusServiceUnavailable, models.APIResponse{
34+
Success: false,
35+
Error: "OANDA price feed unavailable: " + err.Error(),
36+
})
37+
return
38+
}
39+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: prices})
40+
}
41+
42+
func (s *Server) oandaCandles(c *gin.Context) {
43+
instrument := c.Param("instrument")
44+
granularity := c.DefaultQuery("granularity", "H1")
45+
countStr := c.DefaultQuery("count", "100")
46+
count, _ := strconv.Atoi(countStr)
47+
if count <= 0 || count > 5000 {
48+
count = 100
49+
}
50+
51+
candles, err := s.marketData.Oanda.GetCandles(instrument, granularity, count)
52+
if err != nil {
53+
c.JSON(http.StatusServiceUnavailable, models.APIResponse{
54+
Success: false,
55+
Error: "OANDA candle data unavailable: " + err.Error(),
56+
})
57+
return
58+
}
59+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: candles})
60+
}
61+
62+
func (s *Server) oandaInstruments(c *gin.Context) {
63+
instruments, err := s.marketData.Oanda.GetInstruments()
64+
if err != nil {
65+
c.JSON(http.StatusServiceUnavailable, models.APIResponse{
66+
Success: false,
67+
Error: "OANDA instruments unavailable: " + err.Error(),
68+
})
69+
return
70+
}
71+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: instruments})
72+
}
73+
74+
// --- Polygon.io US Equities / NYSE ---
75+
76+
func (s *Server) polygonSnapshot(c *gin.Context) {
77+
ticker := c.Param("ticker")
78+
snapshot, err := s.marketData.Polygon.GetSnapshot(ticker)
79+
if err != nil {
80+
c.JSON(http.StatusServiceUnavailable, models.APIResponse{
81+
Success: false,
82+
Error: "Polygon snapshot unavailable: " + err.Error(),
83+
})
84+
return
85+
}
86+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: snapshot})
87+
}
88+
89+
func (s *Server) polygonAggregates(c *gin.Context) {
90+
ticker := c.Param("ticker")
91+
multiplierStr := c.DefaultQuery("multiplier", "1")
92+
timespan := c.DefaultQuery("timespan", "day")
93+
from := c.Query("from")
94+
to := c.Query("to")
95+
96+
if from == "" || to == "" {
97+
c.JSON(http.StatusBadRequest, models.APIResponse{
98+
Success: false,
99+
Error: "from and to date parameters required (YYYY-MM-DD)",
100+
})
101+
return
102+
}
103+
104+
multiplier, _ := strconv.Atoi(multiplierStr)
105+
if multiplier <= 0 {
106+
multiplier = 1
107+
}
108+
109+
aggs, err := s.marketData.Polygon.GetAggregates(ticker, multiplier, timespan, from, to)
110+
if err != nil {
111+
c.JSON(http.StatusServiceUnavailable, models.APIResponse{
112+
Success: false,
113+
Error: "Polygon aggregates unavailable: " + err.Error(),
114+
})
115+
return
116+
}
117+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: aggs})
118+
}
119+
120+
func (s *Server) polygonTickerDetails(c *gin.Context) {
121+
ticker := c.Param("ticker")
122+
details, err := s.marketData.Polygon.GetTickerDetails(ticker)
123+
if err != nil {
124+
c.JSON(http.StatusServiceUnavailable, models.APIResponse{
125+
Success: false,
126+
Error: "Polygon ticker details unavailable: " + err.Error(),
127+
})
128+
return
129+
}
130+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: details})
131+
}
132+
133+
func (s *Server) polygonSearch(c *gin.Context) {
134+
query := c.Query("q")
135+
market := c.DefaultQuery("market", "stocks")
136+
limitStr := c.DefaultQuery("limit", "20")
137+
limit, _ := strconv.Atoi(limitStr)
138+
if limit <= 0 || limit > 100 {
139+
limit = 20
140+
}
141+
142+
results, err := s.marketData.Polygon.SearchTickers(query, market, limit)
143+
if err != nil {
144+
c.JSON(http.StatusServiceUnavailable, models.APIResponse{
145+
Success: false,
146+
Error: "Polygon search unavailable: " + err.Error(),
147+
})
148+
return
149+
}
150+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: results})
151+
}
152+
153+
func (s *Server) polygonExchanges(c *gin.Context) {
154+
exchanges, err := s.marketData.Polygon.GetExchanges()
155+
if err != nil {
156+
c.JSON(http.StatusServiceUnavailable, models.APIResponse{
157+
Success: false,
158+
Error: "Polygon exchanges unavailable: " + err.Error(),
159+
})
160+
return
161+
}
162+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: exchanges})
163+
}
164+
165+
func (s *Server) polygonMarketStatus(c *gin.Context) {
166+
status, err := s.marketData.Polygon.GetMarketStatus()
167+
if err != nil {
168+
c.JSON(http.StatusServiceUnavailable, models.APIResponse{
169+
Success: false,
170+
Error: "Polygon market status unavailable: " + err.Error(),
171+
})
172+
return
173+
}
174+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: status})
175+
}
176+
177+
// --- IEX Cloud Reference Data / Fundamentals ---
178+
179+
func (s *Server) iexQuote(c *gin.Context) {
180+
symbol := c.Param("symbol")
181+
quote, err := s.marketData.IEX.GetQuote(symbol)
182+
if err != nil {
183+
c.JSON(http.StatusServiceUnavailable, models.APIResponse{
184+
Success: false,
185+
Error: "IEX quote unavailable: " + err.Error(),
186+
})
187+
return
188+
}
189+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: quote})
190+
}
191+
192+
func (s *Server) iexCompany(c *gin.Context) {
193+
symbol := c.Param("symbol")
194+
company, err := s.marketData.IEX.GetCompany(symbol)
195+
if err != nil {
196+
c.JSON(http.StatusServiceUnavailable, models.APIResponse{
197+
Success: false,
198+
Error: "IEX company data unavailable: " + err.Error(),
199+
})
200+
return
201+
}
202+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: company})
203+
}
204+
205+
func (s *Server) iexDividends(c *gin.Context) {
206+
symbol := c.Param("symbol")
207+
rangeParam := c.DefaultQuery("range", "1y")
208+
dividends, err := s.marketData.IEX.GetDividends(symbol, rangeParam)
209+
if err != nil {
210+
c.JSON(http.StatusServiceUnavailable, models.APIResponse{
211+
Success: false,
212+
Error: "IEX dividend data unavailable: " + err.Error(),
213+
})
214+
return
215+
}
216+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: dividends})
217+
}
218+
219+
func (s *Server) iexEarnings(c *gin.Context) {
220+
symbol := c.Param("symbol")
221+
lastStr := c.DefaultQuery("last", "4")
222+
last, _ := strconv.Atoi(lastStr)
223+
if last <= 0 || last > 12 {
224+
last = 4
225+
}
226+
earnings, err := s.marketData.IEX.GetEarnings(symbol, last)
227+
if err != nil {
228+
c.JSON(http.StatusServiceUnavailable, models.APIResponse{
229+
Success: false,
230+
Error: "IEX earnings data unavailable: " + err.Error(),
231+
})
232+
return
233+
}
234+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: earnings})
235+
}
236+
237+
func (s *Server) iexKeyStats(c *gin.Context) {
238+
symbol := c.Param("symbol")
239+
stats, err := s.marketData.IEX.GetKeyStats(symbol)
240+
if err != nil {
241+
c.JSON(http.StatusServiceUnavailable, models.APIResponse{
242+
Success: false,
243+
Error: "IEX stats unavailable: " + err.Error(),
244+
})
245+
return
246+
}
247+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: stats})
248+
}
249+
250+
// --- Economic Calendar & Central Bank Rates ---
251+
252+
func (s *Server) calendarCentralBankRates(c *gin.Context) {
253+
rates := s.marketData.Calendar.GetCentralBankRates()
254+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: rates})
255+
}
256+
257+
func (s *Server) calendarEconomicEvents(c *gin.Context) {
258+
currency := c.Query("currency")
259+
events := s.marketData.Calendar.GetEconomicEvents(currency)
260+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: events})
261+
}
262+
263+
func (s *Server) calendarSwapRates(c *gin.Context) {
264+
rates := s.marketData.Calendar.GetSwapRates()
265+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: rates})
266+
}
267+
268+
func (s *Server) calendarExchangeRates(c *gin.Context) {
269+
rates := s.marketData.Calendar.GetExchangeRates()
270+
c.JSON(http.StatusOK, models.APIResponse{Success: true, Data: rates})
271+
}

0 commit comments

Comments
 (0)