├── .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 | [](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 |
12 |
13 |
14 |
15 | |
16 | {{ .Title }}
17 | {{ with .Stocks }}
18 | {{ range . }}
19 |
20 |
21 | Name | {{ .Name }} |
22 | | |
23 |
24 |
25 | Symbol | {{ .Symbol }} : {{ .Exchange }} |
26 | | |
27 |
28 |
29 | Change |
30 | {{ .Change }} {{ .PercentageChange }}% |
31 | | |
32 |
33 |
34 | Open | {{ .Open }} |
35 | Close | {{ .Close }} |
36 |
37 |
38 | High | {{ .High }} |
39 | Low | {{ .Low }} |
40 |
41 |
42 | Volume | {{ .Volume }} |
43 | Average Volume | {{ .AverageVolume }} |
44 |
45 |
46 | High 52 | {{ .High52 }} |
47 | Low 52 | {{ .Low52 }} |
48 |
49 |
50 | Market Cap | {{ .MarketCap }} |
51 | EPS | {{ .EPS }} |
52 |
53 |
54 | Shares | {{ .Shares }} |
55 | | |
56 |
57 |
58 | Link | {{ .Symbol }} |
59 | | |
60 |
61 |
62 |
63 | {{ end }}
64 | {{ end }}
65 |
66 | |
67 | |
68 |
69 |
70 | |
71 |
72 |
73 |  |
74 |
75 |
76 | click here |
77 |
78 |
79 | LOREM |
80 |
81 |
82 | |
83 |
84 | |
85 | |
86 |
87 | |
88 |
89 | |
90 |
91 |
92 |
93 |
94 |
--------------------------------------------------------------------------------
/tpl/trending.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Go Stock Notifier
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | |
16 | {{ .Title }}
17 | {{ with .Stocks }}
18 | {{ range . }}
19 |
20 |
21 | Trend direction |
22 | |
23 | {{ .TrendingDirection }} |
24 | |
25 |
26 |
27 | Volatility |
28 | |
29 | {{ .VolatilityPerc }}% |
30 | {{ .Volatility }} |
31 |
32 |
33 | Name | {{ .Name }} |
34 | | |
35 |
36 |
37 | Symbol | {{ .Symbol }} : {{ .Exchange }} |
38 | | |
39 |
40 |
41 | Change |
42 | {{ .Change }} {{ .PercentageChange }}% |
43 | | |
44 |
45 |
46 | Open | {{ .Open }} |
47 | Close | {{ .Close }} |
48 |
49 |
50 | High | {{ .High }} |
51 | Low | {{ .Low }} |
52 |
53 |
54 | Volume | {{ .Volume }} |
55 | Average Volume | {{ .AverageVolume }} |
56 |
57 |
58 | High 52 | {{ .High52 }} |
59 | Low 52 | {{ .Low52 }} |
60 |
61 |
62 | Market Cap | {{ .MarketCap }} |
63 | EPS | {{ .EPS }} |
64 |
65 |
66 | Shares | {{ .Shares }} |
67 | | |
68 |
69 |
70 | Link | {{ .Symbol }} |
71 | | |
72 |
73 |
74 |
75 | {{ end }}
76 | {{ end }}
77 |
78 | |
79 | |
80 |
81 |
82 | |
83 |
84 |
85 |  |
86 |
87 |
88 | click here |
89 |
90 |
91 | LOREM |
92 |
93 |
94 | |
95 |
96 | |
97 | |
98 |
99 | |
100 |
101 | |
102 |
103 |
104 |
105 |
106 |
--------------------------------------------------------------------------------