├── .gitignore ├── README.md ├── analysis.go ├── config.json.sample ├── database.go ├── main.go ├── notify.go ├── sanitizeData.go ├── sql └── 0-create_data_table.sql ├── telegram.go └── tpl ├── images ├── PROMO-GREEN2_01_01.jpg ├── PROMO-GREEN2_01_02.jpg ├── PROMO-GREEN2_01_04.jpg ├── PROMO-GREEN2_04_01.jpg ├── PROMO-GREEN2_07.jpg ├── PROMO-GREEN2_09_01.jpg ├── PROMO-GREEN2_09_02.jpg └── PROMO-GREEN2_09_03.jpg ├── notification.html └── trending.html /.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A Stock Notifier using Golang 2 | 3 | [![Join the chat at https://gitter.im/ksred/go-stock-notifier](https://badges.gitter.im/ksred/go-stock-notifier.svg)](https://gitter.im/ksred/go-stock-notifier?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 4 | 5 | _This is a project I am creating to learn the Go language_ 6 | 7 | The aim of this project is to: 8 | - Ingest stock data from a JSON API 9 | - Save data into a relational database 10 | - Run various analysis on the stock data 11 | - Mail a user with periodic updates on selected stocks 12 | - Mail a user with periodic updates of analysis on stock data 13 | 14 | Planned: 15 | - Extend notification system 16 | - Extend analysis systems 17 | - Web frontend 18 | - User accounts 19 | 20 | ### Installation 21 | 22 | Clone this project. 23 | Copy `config.json.sample` to `config.json` and replace all relevant values. 24 | Run all files in the `sql/` directory on the database. 25 | 26 | MIT License 27 | -------------------------------------------------------------------------------- /analysis.go: -------------------------------------------------------------------------------- 1 | // Package analysis contains functions for analysis of stock data 2 | package main 3 | 4 | import ( 5 | "database/sql" 6 | "fmt" 7 | _ "github.com/go-sql-driver/mysql" 8 | "log" 9 | "math" 10 | ) 11 | 12 | type Stock struct { 13 | Symbol string `json:"t"` 14 | Exchange string `json:"e"` 15 | Name string `json:"name"` 16 | Change string `json:"c"` 17 | Close string `json:"l"` 18 | PercentageChange string `json:"cp"` 19 | Open string `json:"op"` 20 | High string `json:"hi"` 21 | Low string `json:"lo"` 22 | Volume string `json:"vo"` 23 | AverageVolume string `json:"avvo"` 24 | High52 string `json:"hi52"` 25 | Low52 string `json:"lo52"` 26 | MarketCap string `json:"mc"` 27 | EPS string `json:"eps"` 28 | Shares string `json:"shares"` 29 | } 30 | 31 | type TrendingStock struct { 32 | *Stock 33 | TrendingDirection string 34 | TrendingStrength int 35 | Volatility float64 36 | VolatilityPerc float64 37 | } 38 | 39 | //@TODO We can use sorting to show the top movers etc 40 | // http://nerdyworm.com/blog/2013/05/15/sorting-a-slice-of-structs-in-go/ 41 | 42 | func CalculateTrends(configuration Configuration, stockList []Stock, db *sql.DB, grouping string, trendDepth int) (trendingStocks []TrendingStock) { 43 | db, err := sql.Open("mysql", configuration.MySQLUser+":"+configuration.MySQLPass+"@tcp("+configuration.MySQLHost+":"+configuration.MySQLPort+")/"+configuration.MySQLDB) 44 | if err != nil { 45 | fmt.Println("Could not connect to database") 46 | return 47 | } 48 | 49 | fmt.Println("\t\t\tChecking for trends") 50 | trendingStocks = make([]TrendingStock, 0) 51 | for i := range stockList { 52 | stock := stockList[i] 53 | 54 | // Prepare statement for inserting data 55 | // Empty vars for initialisation 56 | rows, err := db.Query("") 57 | // Need to manually put string in as it doesnt parse grouping correctly 58 | switch grouping { 59 | // SQL must get only the close price 60 | case "day": 61 | rows, err = db.Query("SELECT `close`, `avgVolume` FROM `st_data` WHERE `symbol` = ? AND `hour` = 17 AND `minute` = 15 ORDER BY `id` DESC LIMIT ?", stock.Symbol, trendDepth) 62 | break 63 | case "hour": 64 | rows, err = db.Query("SELECT `close`, `avgVolume` FROM `st_data` WHERE `symbol` = ? AND `hour` = 17 AND `minute` = 15 ORDER BY `id` DESC LIMIT ?", stock.Symbol, trendDepth) 65 | break 66 | } 67 | //rows, err := db.Query("SELECT `close`, `volume` FROM `st_data` WHERE `symbol` = ? LIMIT 3", stock.Symbol) 68 | if err != nil { 69 | fmt.Println("Error with select query: " + err.Error()) 70 | } 71 | defer rows.Close() 72 | 73 | allCloses := make([]float64, 0) 74 | allVolumes := make([]float64, 0) 75 | count := 0 76 | for rows.Next() { 77 | var stockClose float64 78 | var stockVolume float64 79 | if err := rows.Scan(&stockClose, &stockVolume); err != nil { 80 | log.Fatal(err) 81 | } 82 | allCloses = append(allCloses, stockClose) 83 | allVolumes = append(allVolumes, stockVolume) 84 | count++ 85 | } 86 | if err := rows.Err(); err != nil { 87 | log.Fatal(err) 88 | } 89 | 90 | if count >= 3 { 91 | if doTrendCalculation(allCloses, allVolumes, "up", stock.Symbol, grouping, trendDepth) { 92 | fmt.Printf("\t\t\tTrend UP for %s\n", stock.Symbol) 93 | volatility, volatilityPerc := calculateStdDev(configuration, db, stock.Symbol, 2) 94 | 95 | trendingStock := TrendingStock{&stock, "up", 0, volatility, volatilityPerc} 96 | trendingStocks = append(trendingStocks, trendingStock) 97 | } else if doTrendCalculation(allCloses, allVolumes, "down", stock.Symbol, grouping, trendDepth) { 98 | fmt.Printf("\t\t\tTrend DOWN for %s\n", stock.Symbol) 99 | volatility, volatilityPerc := calculateStdDev(configuration, db, stock.Symbol, 2) 100 | 101 | trendingStock := TrendingStock{&stock, "down", 0, volatility, volatilityPerc} 102 | trendingStocks = append(trendingStocks, trendingStock) 103 | } 104 | } 105 | 106 | } 107 | defer db.Close() 108 | 109 | return 110 | } 111 | 112 | func doTrendCalculation(closes []float64, volumes []float64, trendType string, symbol string, grouping string, trendDepth int) (trending bool) { 113 | /*@TODO 114 | Currently a simple analysis is done on daily stock data. This analysis is to identify trending stocks, with a trend being identified by: 115 | - A price increase (or decrease) each day for three days 116 | - A volume increase (or decrease) over two of the three days 117 | */ 118 | fmt.Printf("\t\t\t\tChecking %s trends with data: price: %f, %f, %f and volume: %f, %f, %f\n", symbol, closes[0], closes[1], closes[2], volumes[0], volumes[1], volumes[2]) 119 | switch trendType { 120 | case "up": 121 | if grouping == "day" { 122 | if closes[0] > closes[1] && closes[1] > closes[2] && (volumes[0] > volumes[2] || volumes[0] > volumes[1]) { 123 | return true 124 | } 125 | } else if grouping == "hour" { 126 | // For now we aim for 3 periods 127 | if closes[0] > closes[1] && closes[1] > closes[2] && (volumes[0] > volumes[2] || volumes[0] > volumes[1]) { 128 | return true 129 | } 130 | } 131 | break 132 | case "down": 133 | if grouping == "day" { 134 | if closes[0] < closes[1] && closes[1] < closes[2] && (volumes[0] > volumes[2] || volumes[0] > volumes[1]) { 135 | return true 136 | } 137 | } else if grouping == "hour" { 138 | // For now we aim for 3 periods 139 | if closes[0] < closes[1] && closes[1] < closes[2] && (volumes[0] > volumes[2] || volumes[0] > volumes[1]) { 140 | return true 141 | } 142 | } 143 | break 144 | } 145 | 146 | return false 147 | } 148 | 149 | func calculateStdDev(configuration Configuration, db *sql.DB, symbol string, decimalPlaces int) (volatility float64, volatilityPerc float64) { 150 | fmt.Println("Calculating standard deviation for symbol " + symbol) 151 | 152 | db, err := sql.Open("mysql", configuration.MySQLUser+":"+configuration.MySQLPass+"@tcp("+configuration.MySQLHost+":"+configuration.MySQLPort+")/"+configuration.MySQLDB) 153 | if err != nil { 154 | fmt.Println("Could not connect to database") 155 | return 156 | } 157 | 158 | // Get all closes for given stock 159 | rows, err := db.Query("SELECT `close` FROM `st_data` WHERE `symbol` = ? GROUP BY `day` LIMIT 365", symbol) // Default is one year's data 160 | if err != nil { 161 | fmt.Println("Error with select query: " + err.Error()) 162 | } 163 | defer rows.Close() 164 | 165 | allCloses := make([]float64, 0) 166 | var totalCloses float64 167 | count := 0. 168 | for rows.Next() { 169 | var stockClose float64 170 | if err := rows.Scan(&stockClose); err != nil { 171 | log.Fatal(err) 172 | } 173 | totalCloses += stockClose 174 | 175 | fmt.Printf("Close at count %f is %f\n", count, stockClose) 176 | allCloses = append(allCloses, stockClose) 177 | count++ 178 | } 179 | if err := rows.Err(); err != nil { 180 | log.Fatal(err) 181 | } 182 | 183 | fmt.Printf("Total closes %f\n", count) 184 | 185 | // Calculate mean 186 | mean := totalCloses / count 187 | fmt.Printf("Mean is %f\n", mean) 188 | 189 | // Get all deviations 190 | deviationsSquare := 0. 191 | for _, cl := range allCloses { 192 | dev := cl - mean 193 | deviationsSquare += dev * dev 194 | } 195 | fmt.Printf("Deviations square is %f\n", deviationsSquare) 196 | 197 | // Calculate average square of deviations 198 | devSquareAvg := deviationsSquare / count 199 | fmt.Printf("Deviations square average is %f\n", devSquareAvg) 200 | 201 | // Volatility is sqrt 202 | volatility = math.Sqrt(devSquareAvg) 203 | 204 | fmt.Printf("Volatility of stock %s is %f\n", symbol, volatility) 205 | 206 | // Make volatility a % so we can judge 207 | volatilityPerc = (volatility / allCloses[int(count)-1]) * 100 208 | fmt.Printf("Volatility of stock %s as percentage is %f\n", symbol, volatilityPerc) 209 | 210 | // Round the volatility 211 | if decimalPlaces != 0 { 212 | volatility = RoundDown(volatility, decimalPlaces) 213 | volatilityPerc = RoundDown(volatilityPerc, decimalPlaces) 214 | } 215 | 216 | defer db.Close() 217 | 218 | return 219 | } 220 | 221 | func RoundDown(input float64, places int) (newVal float64) { 222 | var round float64 223 | pow := math.Pow(10, float64(places)) 224 | digit := pow * input 225 | round = math.Floor(digit) 226 | newVal = round / pow 227 | return 228 | } 229 | -------------------------------------------------------------------------------- /config.json.sample: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "MailSMTPServer" : "mail.server.com", 4 | "MailSMTPPort" : "587", 5 | "MailUser" : "user@mail.com", 6 | "MailPass" : "plaintext_password", 7 | "MailRecipient" : "recipient@mail.com", 8 | "MailSender" : "sender@mail.com", 9 | "Symbols" : ["NASDAQ:GOOGL", "NYSE:BLK"] //Format: exhange:stock 10 | "UpdateInterval" : "100", // in seconds 11 | "TimeZone" : "America/New_York", 12 | "MySQLUser" : "mysql_user", 13 | "MySQLPass" : "mysql_password", 14 | "MySQLHost" : "mysql_ip", 15 | "MySQLPort" : "mysql_port" 16 | "MySQLDB" : "mysql_database" 17 | "TelegramBotApi" : "telegram:apiToken" 18 | } 19 | -------------------------------------------------------------------------------- /database.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | _ "github.com/go-sql-driver/mysql" 7 | "strconv" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | func loadDatabase(configuration *Configuration) (db *sql.DB) { 13 | db, err := sql.Open("mysql", configuration.MySQLUser+":"+configuration.MySQLPass+"@tcp("+configuration.MySQLHost+":"+configuration.MySQLPort+")/"+configuration.MySQLDB) 14 | if err != nil { 15 | fmt.Println("Could not connect to database") 16 | return 17 | } 18 | defer db.Close() 19 | 20 | // Test connection with ping 21 | err = db.Ping() 22 | if err != nil { 23 | fmt.Println("Ping error: " + err.Error()) // proper error handling instead of panic in your app 24 | return 25 | } 26 | 27 | return 28 | } 29 | 30 | func saveToDB(db *sql.DB, stockList []Stock, configuration Configuration) { 31 | db, err := sql.Open("mysql", configuration.MySQLUser+":"+configuration.MySQLPass+"@tcp("+configuration.MySQLHost+":"+configuration.MySQLPort+")/"+configuration.MySQLDB) 32 | if err != nil { 33 | fmt.Println("Could not connect to database") 34 | return 35 | } 36 | 37 | for i := range stockList { 38 | //@TODO Save results to database 39 | stock := stockList[i] 40 | 41 | // Prepare statement for inserting data 42 | insertStatement := "INSERT INTO st_data (`symbol`, `exchange`, `name`, `change`, `close`, `percentageChange`, `open`, `high`, `low`, `volume` , `avgVolume`, `high52` , `low52`, `marketCap`, `eps`, `shares`, `time`, `minute`, `hour`, `day`, `month`, `year`) " 43 | insertStatement += "VALUES( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )" 44 | stmtIns, err := db.Prepare(insertStatement) 45 | if err != nil { 46 | panic(err.Error()) // proper error handling instead of panic in your app 47 | } 48 | defer stmtIns.Close() // Close the statement when we leave main() / the program terminates 49 | 50 | // Convert variables 51 | sqlChange, _ := strconv.ParseFloat(strings.Replace(stock.Change, ",", "", -1), 64) 52 | sqlClose, _ := strconv.ParseFloat(strings.Replace(stock.Close, ",", "", -1), 64) 53 | sqlPercChange, _ := strconv.ParseFloat(stock.PercentageChange, 64) 54 | sqlOpen, _ := strconv.ParseFloat(strings.Replace(stock.Open, ",", "", -1), 64) 55 | sqlHigh, _ := strconv.ParseFloat(strings.Replace(stock.High, ",", "", -1), 64) 56 | sqlLow, _ := strconv.ParseFloat(strings.Replace(stock.Low, ",", "", -1), 64) 57 | sqlHigh52, _ := strconv.ParseFloat(strings.Replace(stock.High52, ",", "", -1), 64) 58 | sqlLow52, _ := strconv.ParseFloat(strings.Replace(stock.Low52, ",", "", -1), 64) 59 | sqlEps, _ := strconv.ParseFloat(stock.EPS, 64) 60 | 61 | // Some contain letters that need to be converted 62 | sqlVolume := convertLetterToDigits(stock.Volume) 63 | sqlAvgVolume := convertLetterToDigits(stock.AverageVolume) 64 | sqlMarketCap := convertLetterToDigits(stock.MarketCap) 65 | sqlShares := convertLetterToDigits(stock.Shares) 66 | 67 | t := time.Now() 68 | utc, err := time.LoadLocation(configuration.TimeZone) 69 | if err != nil { 70 | fmt.Println("err: ", err.Error()) 71 | } 72 | sqlTime := int32(t.Unix()) 73 | sqlMinute := t.In(utc).Minute() 74 | sqlHour := t.In(utc).Hour() 75 | sqlDay := t.In(utc).Day() 76 | sqlMonth := t.In(utc).Month() 77 | sqlYear := t.In(utc).Year() 78 | 79 | _, err = stmtIns.Exec(stock.Symbol, stock.Exchange, stock.Name, sqlChange, sqlClose, 80 | sqlPercChange, sqlOpen, sqlHigh, sqlLow, sqlVolume, sqlAvgVolume, 81 | sqlHigh52, sqlLow52, sqlMarketCap, sqlEps, sqlShares, 82 | sqlTime, sqlMinute, sqlHour, sqlDay, sqlMonth, sqlYear) 83 | 84 | if err != nil { 85 | fmt.Println("Could not save results: " + err.Error()) 86 | } 87 | } 88 | defer db.Close() 89 | } 90 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "flag" 7 | "fmt" 8 | _ "github.com/go-sql-driver/mysql" 9 | "io/ioutil" 10 | "math" 11 | "net/http" 12 | "os" 13 | "time" 14 | ) 15 | 16 | type Configuration struct { 17 | MailUser string 18 | MailPass string 19 | MailSMTPServer string 20 | MailSMTPPort string 21 | MailRecipient string 22 | MailSender string 23 | Symbols []string 24 | UpdateInterval string 25 | TimeZone string 26 | MySQLUser string 27 | MySQLPass string 28 | MySQLHost string 29 | MySQLPort string 30 | MySQLDB string 31 | TelegramBotApi string 32 | TelegramBotID string 33 | } 34 | 35 | const ( 36 | SYMBOL_INTERVAL = 50 37 | ) 38 | 39 | func main() { 40 | 41 | configuration := Configuration{} 42 | loadConfig(&configuration) 43 | 44 | db := loadDatabase(&configuration) 45 | 46 | checkFlags(configuration, db) 47 | 48 | // Start Telegram bot 49 | go startTelegramBot(configuration) 50 | 51 | // Do a loop over symbols 52 | interval := true 53 | count := 0 54 | for interval { 55 | 56 | start := count * SYMBOL_INTERVAL 57 | end := (count + 1) * SYMBOL_INTERVAL 58 | 59 | if end > len(configuration.Symbols) { 60 | end = len(configuration.Symbols) 61 | interval = false 62 | } 63 | 64 | symbolSlice := configuration.Symbols[start:end] 65 | symbolString := convertStocksString(symbolSlice) 66 | 67 | // Yahoo: http://chartapi.finance.yahoo.com/instrument/1.0/msft/chartdata;type=quote;ys=2005;yz=4;ts=1234567890/json 68 | // URL to get detailed company information for a single stock 69 | // var urlDetailed string = "https://www.google.com/finance?q=JSE%3AIMP&q=JSE%3ANPN&ei=TrUBVomhAsKcUsP5mZAG&output=json" 70 | // URL to get broad financials for multiple stocks 71 | var urlStocks string = "https://www.google.com/finance/info?infotype=infoquoteall&q=" + symbolString 72 | 73 | // We check for updates every minute 74 | //duration, _ := time.ParseDuration(configuration.UpdateInterval) 75 | fmt.Printf("Go finance started, slice %d\n", count) 76 | go updateAtInterval(60, urlStocks, configuration, db) 77 | 78 | count++ 79 | } 80 | 81 | select {} // this will cause the program to run forever 82 | } 83 | 84 | func checkFlags(configuration Configuration, db *sql.DB) { 85 | // Check for any flags 86 | testFlag := flag.String("test", "", "Test to run") 87 | symbolFlag := flag.String("symbol", "", "Symbol to run test against") 88 | 89 | flag.Parse() 90 | 91 | // Dereference 92 | flagParsed := *testFlag 93 | symbolParsed := *symbolFlag 94 | 95 | switch flagParsed { 96 | case "trends": 97 | interval := true 98 | count := 0 99 | for interval { 100 | 101 | start := count * SYMBOL_INTERVAL 102 | end := (count + 1) * SYMBOL_INTERVAL 103 | 104 | if end > len(configuration.Symbols) { 105 | end = len(configuration.Symbols) 106 | interval = false 107 | } 108 | 109 | symbolSlice := configuration.Symbols[start:end] 110 | symbolString := convertStocksString(symbolSlice) 111 | var urlStocks string = "https://www.google.com/finance/info?infotype=infoquoteall&q=" + symbolString 112 | body := getDataFromURL(urlStocks) 113 | 114 | jsonString := sanitizeBody("google", body) 115 | 116 | stockList := make([]Stock, 0) 117 | stockList = parseJSONData(jsonString) 118 | 119 | CalculateTrends(configuration, stockList, db, "day", 3) 120 | 121 | count++ 122 | } 123 | os.Exit(0) 124 | 125 | break 126 | case "trendMail": 127 | interval := true 128 | count := 0 129 | for interval { 130 | 131 | start := count * SYMBOL_INTERVAL 132 | end := (count + 1) * SYMBOL_INTERVAL 133 | 134 | if end > len(configuration.Symbols) { 135 | end = len(configuration.Symbols) 136 | interval = false 137 | } 138 | 139 | symbolSlice := configuration.Symbols[start:end] 140 | symbolString := convertStocksString(symbolSlice) 141 | 142 | var urlStocks string = "https://www.google.com/finance/info?infotype=infoquoteall&q=" + symbolString 143 | body := getDataFromURL(urlStocks) 144 | 145 | jsonString := sanitizeBody("google", body) 146 | fmt.Println(jsonString) 147 | 148 | stockList := make([]Stock, 0) 149 | stockList = parseJSONData(jsonString) 150 | 151 | trendingStocks := CalculateTrends(configuration, stockList, db, "day", 3) 152 | if len(trendingStocks) != 0 { 153 | notifyMail := composeMailTemplateTrending(trendingStocks, "trend") 154 | sendMail(configuration, notifyMail) 155 | } 156 | 157 | count++ 158 | } 159 | 160 | os.Exit(0) 161 | 162 | break 163 | case "trendMailHourly": 164 | interval := true 165 | count := 0 166 | for interval { 167 | 168 | start := count * SYMBOL_INTERVAL 169 | end := (count + 1) * SYMBOL_INTERVAL 170 | 171 | if end > len(configuration.Symbols) { 172 | end = len(configuration.Symbols) 173 | interval = false 174 | } 175 | 176 | symbolSlice := configuration.Symbols[start:end] 177 | symbolString := convertStocksString(symbolSlice) 178 | var urlStocks string = "https://www.google.com/finance/info?infotype=infoquoteall&q=" + symbolString 179 | body := getDataFromURL(urlStocks) 180 | 181 | jsonString := sanitizeBody("google", body) 182 | 183 | stockList := make([]Stock, 0) 184 | stockList = parseJSONData(jsonString) 185 | 186 | trendingStocks := CalculateTrends(configuration, stockList, db, "hour", 3) 187 | if len(trendingStocks) != 0 { 188 | notifyMail := composeMailTemplateTrending(trendingStocks, "trend") 189 | sendMail(configuration, notifyMail) 190 | } 191 | 192 | count++ 193 | } 194 | 195 | os.Exit(0) 196 | 197 | break 198 | case "update": 199 | interval := true 200 | count := 0 201 | for interval { 202 | 203 | start := count * SYMBOL_INTERVAL 204 | end := (count + 1) * SYMBOL_INTERVAL 205 | 206 | if end > len(configuration.Symbols) { 207 | end = len(configuration.Symbols) 208 | interval = false 209 | } 210 | 211 | symbolSlice := configuration.Symbols[start:end] 212 | symbolString := convertStocksString(symbolSlice) 213 | var urlStocks string = "https://www.google.com/finance/info?infotype=infoquoteall&q=" + symbolString 214 | body := getDataFromURL(urlStocks) 215 | 216 | jsonString := sanitizeBody("google", body) 217 | 218 | stockList := make([]Stock, 0) 219 | stockList = parseJSONData(jsonString) 220 | 221 | fmt.Println("\t\tOn chosen hours") 222 | notifyMail := composeMailTemplate(stockList, "update") 223 | sendMail(configuration, notifyMail) 224 | 225 | count++ 226 | } 227 | 228 | os.Exit(0) 229 | 230 | break 231 | case "stdDev": 232 | calculateStdDev(configuration, db, symbolParsed, 2) 233 | 234 | os.Exit(0) 235 | break 236 | case "trendBot": 237 | symbolString := convertStocksString(configuration.Symbols) 238 | var urlStocks string = "https://www.google.com/finance/info?infotype=infoquoteall&q=" + symbolString 239 | body := getDataFromURL(urlStocks) 240 | 241 | jsonString := sanitizeBody("google", body) 242 | 243 | stockList := make([]Stock, 0) 244 | stockList = parseJSONData(jsonString) 245 | 246 | trendingStocks := CalculateTrends(configuration, stockList, db, "day", 3) 247 | notifyTelegramTrends(trendingStocks, configuration) 248 | 249 | os.Exit(0) 250 | 251 | break 252 | } 253 | } 254 | 255 | func updateAtInterval(n time.Duration, urlStocks string, configuration Configuration, db *sql.DB) { 256 | 257 | for _ = range time.Tick(n * time.Second) { 258 | t := time.Now() 259 | fmt.Println("BEGIN. Location:", t.Location(), ":Time:", t) 260 | utc, err := time.LoadLocation(configuration.TimeZone) 261 | if err != nil { 262 | fmt.Println("err: ", err.Error()) 263 | return 264 | } 265 | hour := t.In(utc).Hour() 266 | minute := t.In(utc).Minute() 267 | weekday := t.In(utc).Weekday() 268 | 269 | // This must only be run when the markets are open 270 | if weekday != 6 && weekday != 0 && hour >= 9 && hour < 17 { 271 | fmt.Println("\tFalls within operating hours") 272 | fmt.Println(hour) 273 | fmt.Println(minute) 274 | // Save results every 15 minutes 275 | if math.Mod(float64(minute), 15.) == 0 { 276 | fmt.Println("\tFalls within 15 minute interval ") 277 | body := getDataFromURL(urlStocks) 278 | 279 | jsonString := sanitizeBody("google", body) 280 | 281 | stockList := make([]Stock, 0) 282 | stockList = parseJSONData(jsonString) 283 | 284 | saveToDB(db, stockList, configuration) 285 | // Mail every X, here is 2 hours 286 | if minute == 15 { 287 | switch hour { 288 | //@TODO Make this dynamic from config 289 | case 9, 11, 13, 15, 17: 290 | fmt.Println("\t\tOn chosen hours") 291 | notifyMail := composeMailTemplate(stockList, "update") 292 | //sendMail(configuration, notifyMail) 293 | fmt.Println(len(notifyMail)) 294 | break 295 | } 296 | } 297 | } 298 | } 299 | 300 | // Get close 301 | if weekday != 6 && weekday != 0 && hour == 17 && minute == 15 { 302 | body := getDataFromURL(urlStocks) 303 | 304 | jsonString := sanitizeBody("google", body) 305 | 306 | stockList := make([]Stock, 0) 307 | stockList = parseJSONData(jsonString) 308 | 309 | saveToDB(db, stockList, configuration) 310 | 311 | // Send trending update 312 | trendingStocks := CalculateTrends(configuration, stockList, db, "day", 3) 313 | // Send to telegram 314 | notifyTelegramTrends(trendingStocks, configuration) 315 | if len(trendingStocks) != 0 { 316 | notifyMail := composeMailTemplateTrending(trendingStocks, "trend") 317 | sendMail(configuration, notifyMail) 318 | } 319 | } 320 | fmt.Println("END. Location:", t.Location(), ":Time:", t) 321 | } 322 | } 323 | 324 | func loadConfig(configuration *Configuration) { 325 | // Get config 326 | file, _ := os.Open("config.json") 327 | decoder := json.NewDecoder(file) 328 | err := decoder.Decode(&configuration) 329 | if err != nil { 330 | fmt.Println("error:", err) 331 | return 332 | } 333 | } 334 | 335 | func getDataFromURL(urlStocks string) (body []byte) { 336 | resp, err := http.Get(urlStocks) 337 | if err != nil { 338 | // handle error 339 | return 340 | } 341 | defer resp.Body.Close() 342 | body, err = ioutil.ReadAll(resp.Body) 343 | 344 | return 345 | } 346 | -------------------------------------------------------------------------------- /notify.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | "log" 8 | "net/smtp" 9 | "sort" 10 | "strconv" 11 | ) 12 | 13 | type MailTemplate struct { 14 | Title string 15 | Stocks []Stock 16 | } 17 | 18 | type MailTemplateTrending struct { 19 | Title string 20 | Stocks []TrendingStock 21 | } 22 | 23 | type Stocks []Stock 24 | type TrendingStocks []TrendingStock 25 | 26 | func (slice Stocks) Len() int { 27 | return len(slice) 28 | } 29 | 30 | func (slice Stocks) Less(i, j int) bool { 31 | val1, _ := strconv.ParseFloat(slice[i].PercentageChange, 64) 32 | val2, _ := strconv.ParseFloat(slice[j].PercentageChange, 64) 33 | return val1 < val2 34 | } 35 | 36 | func (slice Stocks) Swap(i, j int) { 37 | slice[i], slice[j] = slice[j], slice[i] 38 | } 39 | 40 | // Trending stocks sort 41 | func (slice TrendingStocks) Len() int { 42 | return len(slice) 43 | } 44 | 45 | func (slice TrendingStocks) Less(i, j int) bool { 46 | val1, _ := strconv.ParseFloat(slice[i].PercentageChange, 64) 47 | val2, _ := strconv.ParseFloat(slice[j].PercentageChange, 64) 48 | return val1 < val2 49 | } 50 | 51 | func (slice TrendingStocks) Swap(i, j int) { 52 | slice[i], slice[j] = slice[j], slice[i] 53 | } 54 | 55 | func composeMailTemplateTrending(stockList []TrendingStock, mailType string) (notifyMail string) { 56 | // Order by most gained to most lost 57 | // @TODO Change template to show "top gainers" and "top losers" 58 | displayStocks := TrendingStocks{} 59 | 60 | displayStocks = stockList 61 | sort.Sort(sort.Reverse(displayStocks)) 62 | 63 | // https://jan.newmarch.name/go/template/chapter-template.html 64 | var templateString bytes.Buffer 65 | // Massage data 66 | allStocks := make([]TrendingStock, 0) 67 | for i := range displayStocks { 68 | stock := displayStocks[i] 69 | allStocks = append(allStocks, stock) 70 | } 71 | 72 | mailTpl := MailTemplateTrending{ 73 | Stocks: allStocks, 74 | } 75 | 76 | switch mailType { 77 | case "update": 78 | mailTpl.Title = "Stock update" 79 | t, err := template.ParseFiles("tpl/notification.html") 80 | if err != nil { 81 | fmt.Println("template parse error: ", err) 82 | return 83 | } 84 | err = t.Execute(&templateString, mailTpl) 85 | if err != nil { 86 | fmt.Println("template executing error: ", err) 87 | return 88 | } 89 | break 90 | case "trend": 91 | mailTpl.Title = "Trends update" 92 | t, err := template.ParseFiles("tpl/trending.html") 93 | if err != nil { 94 | fmt.Println("template parse error: ", err) 95 | return 96 | } 97 | err = t.Execute(&templateString, mailTpl) 98 | if err != nil { 99 | fmt.Println("template executing error: ", err) 100 | return 101 | } 102 | break 103 | } 104 | 105 | notifyMail = templateString.String() 106 | 107 | return 108 | } 109 | 110 | func composeMailTemplate(stockList []Stock, mailType string) (notifyMail string) { 111 | // Order by most gained to most lost 112 | // @TODO Change template to show "top gainers" and "top losers" 113 | displayStocks := Stocks{} 114 | 115 | displayStocks = stockList 116 | sort.Sort(sort.Reverse(displayStocks)) 117 | 118 | // https://jan.newmarch.name/go/template/chapter-template.html 119 | var templateString bytes.Buffer 120 | // Massage data 121 | allStocks := make([]Stock, 0) 122 | for i := range displayStocks { 123 | stock := displayStocks[i] 124 | allStocks = append(allStocks, stock) 125 | } 126 | 127 | mailTpl := MailTemplate{ 128 | Stocks: allStocks, 129 | } 130 | 131 | switch mailType { 132 | case "update": 133 | mailTpl.Title = "Stock update" 134 | t, err := template.ParseFiles("tpl/notification.html") 135 | if err != nil { 136 | fmt.Println("template parse error: ", err) 137 | return 138 | } 139 | err = t.Execute(&templateString, mailTpl) 140 | if err != nil { 141 | fmt.Println("template executing error: ", err) 142 | return 143 | } 144 | break 145 | case "trend": 146 | mailTpl.Title = "Trends update" 147 | t, err := template.ParseFiles("tpl/notification.html") 148 | if err != nil { 149 | fmt.Println("template parse error: ", err) 150 | return 151 | } 152 | err = t.Execute(&templateString, mailTpl) 153 | if err != nil { 154 | fmt.Println("template executing error: ", err) 155 | return 156 | } 157 | break 158 | } 159 | 160 | notifyMail = templateString.String() 161 | 162 | return 163 | } 164 | 165 | func composeMailString(stockList []Stock, mailType string) (notifyMail string) { 166 | switch mailType { 167 | case "update": 168 | notifyMail = "Stock Update\n\n" 169 | break 170 | case "trend": 171 | notifyMail = "TRENDING STOCKS\n\n" 172 | break 173 | } 174 | 175 | for i := range stockList { 176 | stock := stockList[i] 177 | notifyMail += fmt.Sprintf("=====================================\n") 178 | notifyMail += fmt.Sprintf("%s\n", stock.Name) 179 | notifyMail += fmt.Sprintf("%s: %s\n", stock.Symbol, stock.Exchange) 180 | notifyMail += fmt.Sprintf("Change: %s : %s%%\n", stock.Change, stock.PercentageChange) 181 | notifyMail += fmt.Sprintf("Open: %s, Close: %s\n", stock.Open, stock.Close) 182 | notifyMail += fmt.Sprintf("High: %s, Low: %s\n", stock.High, stock.Low) 183 | notifyMail += fmt.Sprintf("Volume: %s, Average Volume: %s\n", stock.Volume, stock.AverageVolume) 184 | notifyMail += fmt.Sprintf("High 52: %s, Low 52: %s\n", stock.High52, stock.Low52) 185 | notifyMail += fmt.Sprintf("Market Cap: %s\n", stock.MarketCap) 186 | notifyMail += fmt.Sprintf("EPS: %s\n", stock.EPS) 187 | notifyMail += fmt.Sprintf("Shares: %s\n", stock.Shares) 188 | notifyMail += fmt.Sprintf("=====================================\n") 189 | } 190 | 191 | return 192 | } 193 | 194 | func sendMail(configuration Configuration, notifyMail string) { 195 | // Send email 196 | // Set up authentication information. 197 | auth := smtp.PlainAuth("", configuration.MailUser, configuration.MailPass, configuration.MailSMTPServer) 198 | 199 | // Connect to the server, authenticate, set the sender and recipient, 200 | // and send the email all in one step. 201 | mime := "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n" 202 | to := []string{configuration.MailRecipient} 203 | msg := []byte("To: " + configuration.MailRecipient + "\r\n" + 204 | "Subject: Quote update!\r\n" + 205 | mime + "\r\n" + 206 | "\r\n" + 207 | notifyMail + 208 | "\r\n") 209 | 210 | err := smtp.SendMail(configuration.MailSMTPServer+":"+configuration.MailSMTPPort, auth, configuration.MailSender, to, msg) 211 | if err != nil { 212 | log.Fatal(err) 213 | } 214 | } 215 | 216 | func notifyTelegramTrends(stockList []TrendingStock, configuration Configuration) { 217 | notifyBot := "" 218 | if len(stockList) == 0 { 219 | notifyBot += "No trending stocks" 220 | sendTelegramBotMessage(notifyBot, configuration, 0) 221 | 222 | return 223 | } 224 | 225 | for i := range stockList { 226 | stock := stockList[i] 227 | notifyBot := fmt.Sprintf("%s\n", stock.Name) 228 | notifyBot += fmt.Sprintf("%s: %s\n", stock.Symbol, stock.Exchange) 229 | notifyBot += fmt.Sprintf("Change: %s : %s%%\n", stock.Change, stock.PercentageChange) 230 | notifyBot += fmt.Sprintf("https://www.google.com/finance?q=%s:%s&ei=S0gVVvGqK4vHUdr9joAG\n\n", stock.Symbol, stock.Exchange) 231 | 232 | sendTelegramBotMessage(notifyBot, configuration, 0) 233 | } 234 | 235 | } 236 | -------------------------------------------------------------------------------- /sanitizeData.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | //"encoding/hex" 5 | "bytes" 6 | "encoding/json" 7 | "fmt" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | func parseJSONData(jsonString []byte) (stockList []Stock) { 13 | raw := make([]json.RawMessage, 10) 14 | if err := json.Unmarshal(jsonString, &raw); err != nil { 15 | fmt.Println("error %v", err) 16 | return 17 | } 18 | 19 | for i := 0; i < len(raw); i += 1 { 20 | stock := Stock{} 21 | if err := json.Unmarshal(raw[i], &stock); err != nil { 22 | fmt.Println("error %v", err) 23 | return 24 | } 25 | 26 | stockList = append(stockList, stock) 27 | } 28 | 29 | return 30 | } 31 | 32 | func convertLetterToDigits(withLetter string) (withoutLetter float64) { 33 | // Clear , from string 34 | withLetter = strings.Replace(withLetter, ",", "", -1) 35 | 36 | // First get multiplier 37 | multiplier := 1. 38 | switch { 39 | case strings.Contains(withLetter, "M"): 40 | multiplier = 1000000. 41 | break 42 | case strings.Contains(withLetter, "B"): 43 | multiplier = 1000000000. 44 | break 45 | } 46 | 47 | // Remove the letters 48 | withLetter = strings.Replace(withLetter, "M", "", -1) 49 | withLetter = strings.Replace(withLetter, "B", "", -1) 50 | 51 | // Convert to float 52 | withoutLetter, _ = strconv.ParseFloat(withLetter, 64) 53 | 54 | // Add multiplier 55 | withoutLetter = withoutLetter * multiplier 56 | 57 | return 58 | } 59 | 60 | func convertStocksString(symbols []string) (symbolString string) { 61 | for i := range symbols { 62 | symbol := []byte(symbols[i]) 63 | symbol = bytes.Replace(symbol, []byte(":"), []byte("%3A"), -1) 64 | symbolString += string(symbol) 65 | if i < len(symbols)-1 { 66 | symbolString += "," 67 | } 68 | } 69 | 70 | return 71 | } 72 | 73 | func sanitizeBody(source string, body []byte) (bodyResponse []byte) { 74 | switch source { 75 | case "google": 76 | body = body[4 : len(body)-1] 77 | //body = body[3 : len(body)-1] 78 | 79 | body = bytes.Replace(body, []byte("\\x2F"), []byte("/"), -1) 80 | body = bytes.Replace(body, []byte("\\x26"), []byte("&"), -1) 81 | body = bytes.Replace(body, []byte("\\x3B"), []byte(";"), -1) 82 | body = bytes.Replace(body, []byte("\\x27"), []byte("'"), -1) 83 | } 84 | 85 | bodyResponse = body 86 | 87 | return 88 | } 89 | -------------------------------------------------------------------------------- /sql/0-create_data_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS st_data ( 2 | `id` int NOT NULL AUTO_INCREMENT, 3 | `symbol` varchar(255), 4 | `exchange` varchar(255), 5 | `name` varchar(255), 6 | `change` float, 7 | `close` float, 8 | `percentageChange` float, 9 | `open` float, 10 | `high` float, 11 | `low` float, 12 | `volume` float, 13 | `avgVolume` float, 14 | `high52` float, 15 | `low52` float, 16 | `marketCap` float, 17 | `eps` float, 18 | `shares` float, 19 | `time` int, 20 | `minute` int, 21 | `hour` int, 22 | `day` int, 23 | `month` int, 24 | `year` int, 25 | PRIMARY KEY (`id`) 26 | ); 27 | -------------------------------------------------------------------------------- /telegram.go: -------------------------------------------------------------------------------- 1 | // Telegram functions 2 | package main 3 | 4 | import ( 5 | "database/sql" 6 | "fmt" 7 | "github.com/Syfaro/telegram-bot-api" 8 | _ "github.com/go-sql-driver/mysql" 9 | "log" 10 | "strconv" 11 | "strings" 12 | ) 13 | 14 | func sendTelegramBotMessage(message string, configuration Configuration, replyId int) { 15 | bot, err := tgbotapi.NewBotAPI(configuration.TelegramBotApi) 16 | if err != nil { 17 | log.Fatal(err) 18 | } 19 | 20 | bot.Debug = true 21 | 22 | fmt.Printf("Authorized on account %s", bot.Self.UserName) 23 | botId, err := strconv.Atoi(configuration.TelegramBotID) 24 | if err != nil { 25 | fmt.Println("Could not convert telegram bot id") 26 | return 27 | } 28 | 29 | msg := tgbotapi.NewMessage(botId, message) 30 | if replyId != 0 { 31 | msg.ReplyToMessageID = replyId 32 | } 33 | 34 | bot.Send(msg) 35 | 36 | /* 37 | */ 38 | } 39 | 40 | func startTelegramBot(configuration Configuration) { 41 | bot, err := tgbotapi.NewBotAPI(configuration.TelegramBotApi) 42 | if err != nil { 43 | log.Fatal(err) 44 | } 45 | 46 | bot.Debug = true 47 | 48 | fmt.Printf("Authorized on account %s", bot.Self.UserName) 49 | 50 | u := tgbotapi.NewUpdate(0) 51 | u.Timeout = 60 52 | 53 | updates, err := bot.GetUpdatesChan(u) 54 | 55 | for update := range updates { 56 | 57 | // Parse responses 58 | response := strings.Split(update.Message.Text, " ") 59 | 60 | if response[0] == "" { 61 | sendTelegramBotMessage("Please enter in a command:\nstock [exchange] [symbol], trends", configuration, update.Message.MessageID) 62 | return 63 | } 64 | 65 | switch strings.ToLower(response[0]) { 66 | case "stock": 67 | processStockBotCommand(response, configuration, update.Message.MessageID) 68 | break 69 | case "trends": 70 | // @TODO Abstract this 71 | db := loadDatabase(&configuration) 72 | symbolString := convertStocksString(configuration.Symbols) 73 | var urlStocks string = "https://www.google.com/finance/info?infotype=infoquoteall&q=" + symbolString 74 | body := getDataFromURL(urlStocks) 75 | 76 | jsonString := sanitizeBody("google", body) 77 | 78 | stockList := make([]Stock, 0) 79 | stockList = parseJSONData(jsonString) 80 | 81 | trendingStocks := CalculateTrends(configuration, stockList, db, "day", 3) 82 | notifyTelegramTrends(trendingStocks, configuration) 83 | break 84 | } 85 | 86 | } 87 | } 88 | 89 | func processStockBotCommand(response []string, configuration Configuration, replyId int) { 90 | // Check if all fields are valid 91 | if len(response) < 3 { 92 | sendTelegramBotMessage("Not enough commands! \nUsage: stock [exchange] [symbol]", configuration, replyId) 93 | } 94 | 95 | db, err := sql.Open("mysql", configuration.MySQLUser+":"+configuration.MySQLPass+"@tcp("+configuration.MySQLHost+":"+configuration.MySQLPort+")/"+configuration.MySQLDB) 96 | if err != nil { 97 | fmt.Println("Could not connect to database") 98 | return 99 | } 100 | rows, err := db.Query("SELECT `close`, `avgVolume`, `percentageChange` FROM `st_data` WHERE `exchange` = ? AND `symbol` = ? ORDER BY `id` DESC LIMIT ?", response[1], response[2], 1) 101 | 102 | count := 0 103 | 104 | var stockClose float64 105 | var stockVolume float64 106 | var stockChange float64 107 | for rows.Next() { 108 | if err := rows.Scan(&stockClose, &stockVolume, &stockChange); err != nil { 109 | log.Fatal(err) 110 | } 111 | count++ 112 | } 113 | if err := rows.Err(); err != nil { 114 | log.Fatal(err) 115 | } 116 | 117 | defer rows.Close() 118 | 119 | if count == 0 { 120 | sendTelegramBotMessage("Stock not found", configuration, replyId) 121 | return 122 | } 123 | 124 | message := fmt.Sprintf("%s:%s\nClose: %v\nVolume: %v\nChange: %v%%", response[1], response[2], stockClose, stockVolume, stockChange) 125 | sendTelegramBotMessage(message, configuration, replyId) 126 | } 127 | -------------------------------------------------------------------------------- /tpl/images/PROMO-GREEN2_01_01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ksred/go-stock-notifier/b6d4325b6999984fd2f3a18ee724e2385b28e02f/tpl/images/PROMO-GREEN2_01_01.jpg -------------------------------------------------------------------------------- /tpl/images/PROMO-GREEN2_01_02.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ksred/go-stock-notifier/b6d4325b6999984fd2f3a18ee724e2385b28e02f/tpl/images/PROMO-GREEN2_01_02.jpg -------------------------------------------------------------------------------- /tpl/images/PROMO-GREEN2_01_04.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ksred/go-stock-notifier/b6d4325b6999984fd2f3a18ee724e2385b28e02f/tpl/images/PROMO-GREEN2_01_04.jpg -------------------------------------------------------------------------------- /tpl/images/PROMO-GREEN2_04_01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ksred/go-stock-notifier/b6d4325b6999984fd2f3a18ee724e2385b28e02f/tpl/images/PROMO-GREEN2_04_01.jpg -------------------------------------------------------------------------------- /tpl/images/PROMO-GREEN2_07.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ksred/go-stock-notifier/b6d4325b6999984fd2f3a18ee724e2385b28e02f/tpl/images/PROMO-GREEN2_07.jpg -------------------------------------------------------------------------------- /tpl/images/PROMO-GREEN2_09_01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ksred/go-stock-notifier/b6d4325b6999984fd2f3a18ee724e2385b28e02f/tpl/images/PROMO-GREEN2_09_01.jpg -------------------------------------------------------------------------------- /tpl/images/PROMO-GREEN2_09_02.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ksred/go-stock-notifier/b6d4325b6999984fd2f3a18ee724e2385b28e02f/tpl/images/PROMO-GREEN2_09_02.jpg -------------------------------------------------------------------------------- /tpl/images/PROMO-GREEN2_09_03.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ksred/go-stock-notifier/b6d4325b6999984fd2f3a18ee724e2385b28e02f/tpl/images/PROMO-GREEN2_09_03.jpg -------------------------------------------------------------------------------- /tpl/notification.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Go Stock Notifier 6 | 7 | 8 | 9 | 10 | 11 | 90 | 91 |
12 | 13 | 88 | 89 |
14 | 15 | 16 | 67 | 68 | 69 | 70 | 71 | 85 | 86 | 87 |
 
{{ .Title }}


17 | {{ with .Stocks }} 18 | {{ range . }} 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
Name{{ .Name }}
Symbol{{ .Symbol }} : {{ .Exchange }}
Change{{ .Change }} {{ .PercentageChange }}%
Open{{ .Open }}Close{{ .Close }}
High{{ .High }}Low{{ .Low }}
Volume{{ .Volume }}Average Volume{{ .AverageVolume }}
High 52{{ .High52 }}Low 52{{ .Low52 }}
Market Cap{{ .MarketCap }}EPS{{ .EPS }}
Shares{{ .Shares }}
Link{{ .Symbol }}
62 |

63 | {{ end }} 64 | {{ end }} 65 | 66 |
 
  72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 |
click here
LOREM
 
92 | 93 | 94 | -------------------------------------------------------------------------------- /tpl/trending.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Go Stock Notifier 6 | 7 | 8 | 9 | 10 | 11 | 102 | 103 |
12 | 13 | 100 | 101 |
14 | 15 | 16 | 79 | 80 | 81 | 82 | 83 | 97 | 98 | 99 |
 
{{ .Title }}


17 | {{ with .Stocks }} 18 | {{ range . }} 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 |
Trend direction{{ .TrendingDirection }}
Volatility{{ .VolatilityPerc }}%{{ .Volatility }}
Name{{ .Name }}
Symbol{{ .Symbol }} : {{ .Exchange }}
Change{{ .Change }} {{ .PercentageChange }}%
Open{{ .Open }}Close{{ .Close }}
High{{ .High }}Low{{ .Low }}
Volume{{ .Volume }}Average Volume{{ .AverageVolume }}
High 52{{ .High52 }}Low 52{{ .Low52 }}
Market Cap{{ .MarketCap }}EPS{{ .EPS }}
Shares{{ .Shares }}
Link{{ .Symbol }}
74 |

75 | {{ end }} 76 | {{ end }} 77 | 78 |
 
  84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 |
click here
LOREM
 
104 | 105 | 106 | --------------------------------------------------------------------------------