Skip to content

Commit bfade41

Browse files
Add stock market functionality and localization support
- Introduced `ShareSansarStockProvider` for fetching stock market data from ShareSansar, including latest market snapshots and stock quotes. - Added `StockMarketSnapshot`, `StockQuote`, and `MarketIndex` models to represent stock data and market indices. - Implemented a new `StocksSection` view in the Bazar feature to display stock information, including a search field and watchlist functionality. - Enhanced `AppModel` to manage stock data and user watchlists, allowing users to add or remove stocks from their watchlist. - Expanded localization support in `Localizable.strings` for both English and Nepali, adding new keys related to stock features. - Updated `NepaliNumberFormatter` to maintain decimal precision for market prices. - Added unit tests for stock-related functionality to ensure data integrity and correct behavior. - Improved UI components in `BazarView` to accommodate the new stocks section, enhancing user experience.
1 parent 17e70b2 commit bfade41

16 files changed

Lines changed: 1511 additions & 11 deletions

Sources/SajiloApp/Core/Foundation/AppLanguage.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,32 @@ enum L10n {
6363
static let numerals = LocalizedStringResource("settings.numerals", bundle: .sajiloResources)
6464
static let news = LocalizedStringResource("screen.news", bundle: .sajiloResources)
6565
static let bazar = LocalizedStringResource("screen.bazar", bundle: .sajiloResources)
66+
static let bazarNotTradedToday = LocalizedStringResource("bazar.not-traded-today", bundle: .sajiloResources)
67+
static let stocksGainers = LocalizedStringResource("stocks.gainers", bundle: .sajiloResources)
68+
static let stocksLosers = LocalizedStringResource("stocks.losers", bundle: .sajiloResources)
69+
static let stocksTurnover = LocalizedStringResource("stocks.turnover", bundle: .sajiloResources)
70+
static let stocksVolume = LocalizedStringResource("stocks.volume", bundle: .sajiloResources)
71+
static let stocksSearch = LocalizedStringResource("stocks.search", bundle: .sajiloResources)
72+
static let stocksWatchlist = LocalizedStringResource("stocks.watchlist", bundle: .sajiloResources)
73+
static let stocksSectors = LocalizedStringResource("stocks.sectors", bundle: .sajiloResources)
74+
static let stocksMovers = LocalizedStringResource("stocks.movers", bundle: .sajiloResources)
75+
static let stocksNoMatch = LocalizedStringResource("stocks.no-match", bundle: .sajiloResources)
76+
static let stocksEmptyWatchlist = LocalizedStringResource("stocks.empty-watchlist", bundle: .sajiloResources)
77+
static let stocksDayRange = LocalizedStringResource("stocks.day-range", bundle: .sajiloResources)
78+
static let stocksWeek52 = LocalizedStringResource("stocks.week52", bundle: .sajiloResources)
79+
static let stocksOpen = LocalizedStringResource("stocks.open", bundle: .sajiloResources)
80+
static let stocksHigh = LocalizedStringResource("stocks.high", bundle: .sajiloResources)
81+
static let stocksLow = LocalizedStringResource("stocks.low", bundle: .sajiloResources)
82+
static let stocksPrevClose = LocalizedStringResource("stocks.prev-close", bundle: .sajiloResources)
83+
static let stocksVwap = LocalizedStringResource("stocks.vwap", bundle: .sajiloResources)
84+
static let stocksTraded = LocalizedStringResource("stocks.traded", bundle: .sajiloResources)
85+
static let stocksTrades = LocalizedStringResource("stocks.trades", bundle: .sajiloResources)
86+
static let stocksAvg120 = LocalizedStringResource("stocks.avg-120", bundle: .sajiloResources)
87+
static let stocksAvg180 = LocalizedStringResource("stocks.avg-180", bundle: .sajiloResources)
88+
static let stocksFollow = LocalizedStringResource("stocks.follow", bundle: .sajiloResources)
89+
static let stocksUnfollow = LocalizedStringResource("stocks.unfollow", bundle: .sajiloResources)
90+
static let stocksOpenSharesansar = LocalizedStringResource("stocks.open-sharesansar", bundle: .sajiloResources)
91+
static let stocksWatchlistFull = LocalizedStringResource("stocks.watchlist-full", bundle: .sajiloResources)
6692
static let rashifal = LocalizedStringResource("screen.rashifal", bundle: .sajiloResources)
6793
static let radio = LocalizedStringResource("screen.radio", bundle: .sajiloResources)
6894
static let rashifalPickSign = LocalizedStringResource("rashifal.pick-sign", bundle: .sajiloResources)
@@ -76,6 +102,11 @@ enum L10n {
76102
static let radioSearch = LocalizedStringResource("radio.search", bundle: .sajiloResources)
77103
static let radioNoStations = LocalizedStringResource("radio.no-stations", bundle: .sajiloResources)
78104
static let bazarMetals = LocalizedStringResource("bazar.metals", bundle: .sajiloResources)
105+
static let bazarStocks = LocalizedStringResource("bazar.stocks", bundle: .sajiloResources)
106+
static let bazarWatchlist = LocalizedStringResource("bazar.watchlist", bundle: .sajiloResources)
107+
static let bazarAddTicker = LocalizedStringResource("bazar.add-ticker", bundle: .sajiloResources)
108+
static let bazarNoWatchlist = LocalizedStringResource("bazar.no-watchlist", bundle: .sajiloResources)
109+
static let bazarMarketTurnover = LocalizedStringResource("bazar.market-turnover", bundle: .sajiloResources)
79110
static let bazarFuel = LocalizedStringResource("bazar.fuel", bundle: .sajiloResources)
80111
static let bazarVegetables = LocalizedStringResource("bazar.vegetables", bundle: .sajiloResources)
81112
static let bazarSearchProduce = LocalizedStringResource("bazar.search-produce", bundle: .sajiloResources)
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import Foundation
2+
3+
protocol StockMarketProviding: Sendable {
4+
func latestMarket() async throws -> StockMarketSnapshot
5+
}
6+
7+
/// Reads ShareSansar's public, server-rendered market and price tables. A
8+
/// single price-table request supplies every watched symbol, rather than
9+
/// requesting individual company pages as the watchlist grows.
10+
struct ShareSansarStockProvider: StockMarketProviding {
11+
private let session: URLSession
12+
13+
init(session: URLSession? = nil) {
14+
self.session = session ?? .sajilo()
15+
}
16+
17+
private static let marketURL = URL(string: "https://www.sharesansar.com/index.php/market")!
18+
private static let pricesURL = URL(string: "https://www.sharesansar.com/index.php/today-share-price")!
19+
20+
func latestMarket() async throws -> StockMarketSnapshot {
21+
async let marketHTML = fetch(Self.marketURL)
22+
async let pricesHTML = fetch(Self.pricesURL)
23+
let (market, prices) = try await (marketHTML, pricesHTML)
24+
return try Self.parse(marketHTML: market, pricesHTML: prices, fetchedAt: .now)
25+
}
26+
27+
private func fetch(_ url: URL) async throws -> String {
28+
var request = URLRequest(url: url)
29+
request.setValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) Sajilo/1.0", forHTTPHeaderField: "User-Agent")
30+
let (data, response) = try await session.data(for: request)
31+
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode,
32+
let html = String(data: data, encoding: .utf8) else {
33+
throw StockMarketProviderError.invalidResponse
34+
}
35+
return html
36+
}
37+
38+
static func parse(marketHTML: String, pricesHTML: String, fetchedAt: Date) throws -> StockMarketSnapshot {
39+
let quotes = try quotes(in: pricesHTML)
40+
let indices = indices(in: marketHTML)
41+
return StockMarketSnapshot(
42+
nepse: indices.first { $0.name.localizedCaseInsensitiveContains("NEPSE") },
43+
subIndices: subIndices(in: marketHTML),
44+
movers: movers(in: marketHTML),
45+
quotes: quotes,
46+
publishedOn: publishedDate(in: pricesHTML) ?? publishedDate(in: marketHTML),
47+
fetchedAt: fetchedAt
48+
)
49+
}
50+
51+
static func quotes(in html: String) throws -> [StockQuote] {
52+
guard let table = HTMLTable.allTableRows(in: html).first(where: { rows in
53+
rows.first?.contains("Symbol") == true && rows.first?.contains("LTP") == true
54+
}) else { throw StockMarketProviderError.tableNotFound }
55+
56+
let companyNames = companyNames(in: html)
57+
let quotes = table.dropFirst().compactMap { row -> StockQuote? in
58+
// S.No, Symbol, Conf., Open, High, Low, Close, LTP, Close-LTP,
59+
// Close-LTP %, VWAP, Vol, Prev. Close, Turnover, Trans., Diff,
60+
// Range, Diff %, Range %, VWAP %, 120 Days, 180 Days,
61+
// 52 Weeks High, 52 Weeks Low.
62+
guard row.count >= 18,
63+
let ltp = number(row[7]),
64+
let previousClose = number(row[12]),
65+
let turnover = number(row[13]),
66+
let change = number(row[15]),
67+
let changePercent = number(row[17]) else { return nil }
68+
let symbol = row[1].trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
69+
guard !symbol.isEmpty else { return nil }
70+
71+
// The trailing columns are read where present and left nil where
72+
// not, so a narrower table still yields a usable quote rather than
73+
// dropping the row.
74+
func optional(_ index: Int) -> Double? {
75+
index < row.count ? number(row[index]) : nil
76+
}
77+
78+
return StockQuote(
79+
symbol: symbol,
80+
companyName: companyNames[symbol],
81+
ltp: ltp,
82+
previousClose: previousClose,
83+
change: change,
84+
changePercent: changePercent,
85+
open: optional(3),
86+
high: optional(4),
87+
low: optional(5),
88+
close: optional(6),
89+
vwap: optional(10),
90+
volume: optional(11),
91+
turnover: turnover,
92+
transactions: optional(14),
93+
week52High: optional(22),
94+
week52Low: optional(23),
95+
average120Day: optional(20),
96+
average180Day: optional(21)
97+
)
98+
}
99+
guard !quotes.isEmpty else { throw StockMarketProviderError.tableNotFound }
100+
return quotes
101+
}
102+
103+
/// The headline indices — NEPSE and its siblings — share a table shape with
104+
/// the sector sub-indices, so one reader serves both.
105+
static func indices(in html: String) -> [MarketIndex] {
106+
parseIndexTable(in: html, headingContains: "Index")
107+
}
108+
109+
/// Banking, Hydropower, Microfinance and the rest. The headline index says
110+
/// the market moved; these say where.
111+
static func subIndices(in html: String) -> [MarketIndex] {
112+
parseIndexTable(in: html, headingContains: "Sub Index")
113+
}
114+
115+
private static func parseIndexTable(in html: String, headingContains heading: String) -> [MarketIndex] {
116+
guard let table = HTMLTable.allTableRows(in: html).first(where: { rows in
117+
guard let header = rows.first else { return false }
118+
return header.contains(where: { $0.trimmingCharacters(in: .whitespaces) == heading })
119+
&& header.contains { $0.contains("Point") }
120+
}) else { return [] }
121+
122+
return table.dropFirst().compactMap { row in
123+
// Name, Open, High, Low, Close, Point, % Change, Turnover.
124+
guard row.count >= 8 else { return nil }
125+
let name = row[0].trimmingCharacters(in: .whitespacesAndNewlines)
126+
guard !name.isEmpty,
127+
let value = number(row[4]), let change = number(row[5]),
128+
let percent = number(row[6]), let turnover = number(row[7]) else { return nil }
129+
return MarketIndex(
130+
name: name,
131+
value: value,
132+
change: change,
133+
changePercent: percent,
134+
turnover: turnover
135+
)
136+
}
137+
}
138+
139+
/// The four leaderboards.
140+
///
141+
/// Two traps here, both found by running this against the live page.
142+
/// Gainers and losers are *separate* tables with byte-identical headers —
143+
/// matching on the header alone finds the gainers twice and leaves losers
144+
/// empty — so they are taken in document order. And the two money tables
145+
/// put their metric first and the price second, the opposite of the
146+
/// percent tables, so every column index is stated rather than assumed.
147+
static func movers(in html: String) -> [MarketMover] {
148+
let tables = HTMLTable.allTableRows(in: html)
149+
150+
func rows(matching heading: String) -> [[[String]]] {
151+
tables.filter { table in
152+
guard let header = table.first, header.count >= 3 else { return false }
153+
return header[0].trimmingCharacters(in: .whitespaces) == "Symbol"
154+
&& header.contains { $0.localizedCaseInsensitiveContains(heading) }
155+
}
156+
}
157+
158+
func parse(_ table: [[String]], board: MarketMover.Board, ltp: Int, metric: Int) -> [MarketMover] {
159+
table.dropFirst().compactMap { row in
160+
guard row.count > max(ltp, metric) else { return nil }
161+
let symbol = row[0].trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
162+
guard !symbol.isEmpty, let metricValue = number(row[metric]) else { return nil }
163+
return MarketMover(
164+
board: board,
165+
symbol: symbol,
166+
ltp: number(row[ltp]) ?? 0,
167+
metric: metricValue
168+
)
169+
}
170+
}
171+
172+
// Symbol, LTP(Rs), Point Change, % Change — gainers first, then losers.
173+
let byPercent = rows(matching: "% Change")
174+
var movers: [MarketMover] = []
175+
if let gainers = byPercent.first {
176+
movers += parse(gainers, board: .gainers, ltp: 1, metric: 3)
177+
}
178+
if byPercent.count > 1 {
179+
movers += parse(byPercent[1], board: .losers, ltp: 1, metric: 3)
180+
}
181+
182+
// Symbol, TurnOvers(Rs), Ltp(Rs) — and the same shape for Volume.
183+
if let turnover = rows(matching: "TurnOver").first {
184+
movers += parse(turnover, board: .turnover, ltp: 2, metric: 1)
185+
}
186+
if let volume = rows(matching: "Volume").first {
187+
movers += parse(volume, board: .volume, ltp: 2, metric: 1)
188+
}
189+
return movers
190+
}
191+
192+
static func number(_ text: String) -> Double? {
193+
Double(text.replacingOccurrences(of: ",", with: "").trimmingCharacters(in: .whitespacesAndNewlines))
194+
}
195+
196+
/// The page embeds its search directory as a JSON array. Reading names
197+
/// from that already-downloaded directory avoids one company-page request
198+
/// per watched ticker and keeps all watchlist rows from the same session.
199+
static func companyNames(in html: String) -> [String: String] {
200+
guard let marker = html.range(of: "var cmpjson ="),
201+
let start = html[marker.upperBound...].firstIndex(of: "["),
202+
let end = html[start...].range(of: "];"),
203+
let data = String(html[start...end.lowerBound]).data(using: .utf8),
204+
let records = try? JSONDecoder().decode([CompanyRecord].self, from: data) else {
205+
return [:]
206+
}
207+
return Dictionary(uniqueKeysWithValues: records.map { ($0.symbol.uppercased(), $0.companyname) })
208+
}
209+
210+
static func publishedDate(in html: String) -> Date? {
211+
let pattern = #"\b20\d{2}-\d{2}-\d{2}\b"#
212+
guard let range = html.range(of: pattern, options: .regularExpression) else { return nil }
213+
let formatter = DateFormatter()
214+
formatter.locale = Locale(identifier: "en_US_POSIX")
215+
formatter.dateFormat = "yyyy-MM-dd"
216+
return formatter.date(from: String(html[range]))
217+
}
218+
219+
private struct CompanyRecord: Decodable {
220+
let symbol: String
221+
let companyname: String
222+
}
223+
}
224+
225+
enum StockMarketProviderError: Error, Equatable {
226+
case invalidResponse
227+
case tableNotFound
228+
}

Sources/SajiloApp/Core/Networking/HTMLTable.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,18 @@ import Foundation
88
/// prices — so both read through this rather than each growing its own parser.
99
enum HTMLTable {
1010
static func firstTableRows(in html: String) -> [[String]] {
11-
guard let table = slice(of: html, tag: "table").first else { return [] }
12-
return slice(of: table, tag: "tr")
13-
.map(cells(in:))
14-
.filter { !$0.isEmpty }
11+
allTableRows(in: html).first ?? []
12+
}
13+
14+
/// Every table, preserving document order. Sources such as ShareSansar put
15+
/// multiple independent datasets on one page, so callers select by their
16+
/// explicit header rather than relying on a fragile table position.
17+
static func allTableRows(in html: String) -> [[[String]]] {
18+
slice(of: html, tag: "table").map { table in
19+
slice(of: table, tag: "tr")
20+
.map(cells(in:))
21+
.filter { !$0.isEmpty }
22+
}
1523
}
1624

1725
/// Cells in document order, so a heading row mixing `th` and `td` keeps its

Sources/SajiloApp/Core/Persistence/SajiloBackup.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ struct SajiloBackup: Codable, Equatable, Sendable {
2121
var weatherLocation: String
2222
var forexFavourites: [String]
2323
var vegetableFavourites: [String]
24+
/// Optional so v1 exports made before the watchlist existed still
25+
/// import cleanly.
26+
var stockWatchlist: [String]? = nil
2427
var selectedRashi: String?
2528
var showsDockIcon: Bool
2629
var notifyHolidayEve: Bool

Sources/SajiloApp/Core/Tools/NepaliUnits.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,17 @@ enum NepaliNumberFormatter {
113113
return (negative ? "-" : "") + (groups + [tail]).joined(separator: ",")
114114
}
115115

116+
/// Market feeds publish prices to two decimal places. Keep that precision:
117+
/// a quoted LTP of 722.90 must never become the materially different 723.
118+
static func grouped(_ value: Double, fractionDigits: Int) -> String {
119+
let formatted = String(format: "%.*f", locale: Locale(identifier: "en_US_POSIX"), fractionDigits, value)
120+
let pieces = formatted.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: false)
121+
guard let whole = Int(pieces[0]) else { return formatted }
122+
let integer = grouped(whole)
123+
guard fractionDigits > 0, pieces.count == 2 else { return integer }
124+
return "\(integer).\(pieces[1])"
125+
}
126+
116127
/// "1 crore 25 lakh" — how the figure is actually said aloud.
117128
static func scaleDescription(_ value: Int) -> String? {
118129
let magnitude = abs(value)

0 commit comments

Comments
 (0)