001package hirondelle.stocks.quotes; 002 003import java.util.*; 004import java.util.logging.*; 005import java.io.*; 006import java.net.*; 007import java.math.BigDecimal; 008import javax.swing.ProgressMonitorInputStream; 009 010import hirondelle.stocks.util.Consts; 011import hirondelle.stocks.util.Util; 012import hirondelle.stocks.util.Args; 013import hirondelle.stocks.util.DataAccessException; 014 015/** 016* Given a set of stocks in a {@link hirondelle.stocks.portfolio.Portfolio}, will retrieve 017* current price information from the web, and return corresponding 018* {@link Quote} objects. 019*/ 020public final class QuotesDAO { 021 022 /** 023 * Constructor. 024 * 025 * @param aUseMonitor indicates if a <tt>ProgressMonitor</tt> should be used 026 * during fetch operations. 027 * @param aStocks is a possibly-empty collection of {@link Stock} objects, 028 * with a definite iteration order. 029 */ 030 public QuotesDAO(UseMonitor aUseMonitor, Collection<Stock> aStocks){ 031 Args.checkForNull(aStocks); 032 fStocks = aStocks; 033 fUseMonitor = aUseMonitor.getValue(); 034 } 035 036 /** 037 * Enumeration for the two states of aUseMonitor passed to the 038 * constructor. 039 */ 040 public enum UseMonitor { 041 TRUE(true), 042 FALSE(false); 043 boolean getValue() { 044 return fToggle; 045 } 046 private final boolean fToggle; 047 private UseMonitor (boolean aToggle) { 048 fToggle = aToggle; 049 } 050 } 051 052 /** 053 * Fetch current stock price data from the web. 054 * 055 * @return List of {@link Quote} objects, whose size and iteration 056 * order matches that of the collection of <tt>Stock</tt> objects passed to the 057 * constructor; if that collection is empty, then return an empty <tt>List</tt>; if 058 * a ticker is invalid, then it is still included in the result, but its price 059 * will be zero. 060 */ 061 public List<Quote> getQuotes() throws DataAccessException { 062 /* 063 * Implementation Notes: 064 * Uses a Yahoo service, which outputs a simple textual (non-html) String 065 * in response to a query regarding stock prices. 066 * Example URL for a single stock (NT.TO symbol in this case): <br> 067 * http://quote.yahoo.com/d/quotes.csv?s=NT.TO&f=sl1d1t1c1ohgv&e=.csv 068 * 069 * Multiple selections are simply comma-separated as in: <br> 070 * http://quote.yahoo.com/d/quotes.csv?s=NT.TO,SUNW&f=sl1d1t1c1ohgv&e=.csv <br> 071 * 072 * which outputs the following:<br> 073 * "NT.TO",3.59,"12/2/2002","4:53pm",0.00,N/A,N/A,N/A,0<br> 074 * "SUNW",4.14,"12/3/2002","4:00pm",-0.15,4.56,4.58,4.12,46700<br> 075 * 076 * The fields appear in this order:<br> 077 * ticker, last trade, date-last-trade, time-last-trade, change, open,hi, lo,volume<br> 078 * 079 * The Yahoo service limits request to a maximum of 200 quotations. 080 * Indexes begin with a special character, eg "^DJI" is the Dow Jones. 081 * Example currency conversion: "THBMGF=X", which gives a quote between 082 * THB - Thai baht - and MGF - Malagasy franc. The result of this query is: <br> 083 * "THBMGF=X",149.4387,"1/22/2003","10:19am",N/A,N/A,N/A,N/A,N/A 084 */ 085 if (OFF_LINE) { 086 System.out.println("HARD - fixed quote prices"); 087 return getStaticQuotes(); //debugging only 088 } 089 if (fStocks.size() == 0) return Collections.emptyList(); 090 091 URL yahooUrl = null; 092 try { 093 yahooUrl = new URL(getYahooUrlText()); 094 } 095 catch (MalformedURLException ex){ 096 fLogger.severe("Cannot create Yahoo Url using: " + getYahooUrlText()); 097 } 098 List<Quote> queryResult = getQueryResult(yahooUrl); 099 //fLogger.fine("Query result: " + queryResult); 100 return queryResult; 101 } 102 103 // PRIVATE 104 105 /** 106 * Developers may set this environment variable to true in order to operate 107 * offline, such that hard-coded quote information is used instead of being 108 * fetched from the web. 109 * 110 * In the development environemnt, add a custom property to the command line, if 111 * desired, as in : 112 * in "java -Doffline=true -jar StocksMonitor.jar" 113 */ 114 private static final boolean OFF_LINE = Boolean.getBoolean("offline"); 115 116 /** 117 * The collection of {@link Stock} objects for which quotes will be retrieved. 118 */ 119 private Collection<Stock> fStocks; 120 121 /** 122 * If true, then a graphical progress indicator is to be displayed 123 * to the user while long-running operations take place. 124 * 125 * The GUI is only displayed if the underlying operation takes more than 126 * a few seconds. 127 * 128 * (In practice, since the amount of data being 129 * retrieved is usually small, the gui element will 130 * almost never be displayed. Tests with 200 quotes, which 131 * is the max allowable by Yahoo, showed no Progress Bar.) 132 */ 133 private boolean fUseMonitor; 134 135 private static final Logger fLogger = 136 Logger.getLogger(QuotesDAO.class.getPackage().getName()) 137 ; 138 139 private static final String fYAHOO_URL_START = "http://quote.yahoo.com/d/quotes.csv?s="; 140 private static final String fYAHOO_URL_END = "&f=sl1d1t1c1ohgv&e=.csv"; 141 142 private BigDecimal ZERO = Consts.ZERO_MONEY_WITH_DECIMAL; 143 private int ROUND_MODE = Consts.MONEY_ROUNDING_STYLE; 144 private int DECIMALS = Consts.MONEY_DECIMAL_PLACES; 145 146 /** 147 * Return the HTTP URL to be used for fetching stock data from Yahoo, 148 * represented as a String. 149 */ 150 private String getYahooUrlText(){ 151 StringBuilder result = new StringBuilder(fYAHOO_URL_START); 152 Iterator<Stock> stocksIter = fStocks.iterator(); 153 while (stocksIter.hasNext()){ 154 Stock stock = stocksIter.next(); 155 result.append(getTickerForYahooUrl(stock)); 156 if ( stocksIter.hasNext() ) { 157 result.append(Consts.COMMA); 158 } 159 } 160 result.append(fYAHOO_URL_END); 161 return result.toString(); 162 163 //Exercises a large number of quotes (the max allowed by Yahoo is 200) 164 // StringBuilder result = new StringBuilder(fYAHOO_URL_START); 165 // for ( int idx = 0; idx<200 ; ++idx) { 166 // result.append("SUNW"); 167 // if ( idx < 199 ) { 168 // result.append(","); 169 // } 170 // } 171 // result.append(fYAHOO_URL_END); 172 // return result.toString(); 173 } 174 175 /** 176 * Return the custom stock ticker symbol used by Yahoo, which may contain 177 * a suffix representing the Exchange. (The Exchange suffix is not used for some 178 * common US exchanges.) 179 */ 180 private String getTickerForYahooUrl( Stock aStock ) { 181 StringBuilder result = new StringBuilder(aStock.getTicker ()); 182 String exchangeSuffix = aStock.getExchange().getTickerSuffix(); 183 if (Util.textHasContent(exchangeSuffix)){ 184 result.append("."); 185 result.append(exchangeSuffix); 186 } 187 return result.toString(); 188 } 189 190 /** 191 * Return List of {@link Quote} objects. 192 */ 193 private List<Quote> getQueryResult(URL aHttpRequest) throws DataAccessException { 194 /* 195 * This implementation depends on determinate iteration orders. 196 * That is, the iteration 197 * order used here is identical to that used to generate the query. 198 * As well, the order of items in the response must match the order 199 * in the request. 200 * This is necessary only because the exchange mapping is not one-to-one. 201 */ 202 List<Quote> result = new ArrayList<>(); 203 List<String> lines = getLines(aHttpRequest); 204 int rowIdx = 0; 205 for(Stock stock : fStocks){ 206 result.add(getQuote(stock, lines.get(rowIdx))); 207 ++rowIdx; 208 } 209 return result; 210 } 211 212 /** 213 * Translate the response from Yahoo into a List of lines of text. 214 * This is needed only because the Yahoo response is not readily parsed 215 * back into exchange and stock, since the mapping is not quite one-to-one. 216 */ 217 private List<String> getLines(URL aHttpRequest) throws DataAccessException { 218 List<String> result = new ArrayList<>(); 219 try { 220 InputStream input = null; 221 LineNumberReader htmlReader = null; 222 try { 223 if ( fUseMonitor ){ 224 //This error may occur inside an IDE: 225 //java.net.SocketException: Unrecognized Windows Sockets error: 10106: create 226 input = new ProgressMonitorInputStream( 227 null, "Fetching.", aHttpRequest.openStream() 228 ); 229 } 230 else { 231 input = aHttpRequest.openStream(); 232 } 233 htmlReader = new LineNumberReader(new InputStreamReader(input)); 234 String line = null; 235 while ( (line = htmlReader.readLine())!= null ) { 236 result.add(line); 237 } 238 } 239 finally { 240 if (htmlReader != null) htmlReader.close(); 241 if (input != null) input.close(); 242 } 243 } 244 catch(IOException ex) { 245 throw new DataAccessException("Please connect to the Web. Cannot access Yahoo.", ex); 246 } 247 return result; 248 } 249 250 /** 251 * Return Quote object corresponding to a single line of a Yahoo 252 * query response. 253 */ 254 private Quote getQuote(Stock aStock, String aQueryResultLine) { 255 //The fields appear in this order: 256 //ticker, last-trade, date-last-trade, time-last-trade, change, open, hi, lo, volume 257 StringTokenizer parser = new StringTokenizer(aQueryResultLine, Consts.COMMA); 258 259 //confirm that this line matches aStock's ticker 260 String ticker = parseTicker(parser.nextToken()); 261 if ( !ticker.startsWith(aStock.getTicker()) ) { 262 fLogger.severe( 263 "Invalid ticker-exchange? Expected line for " + aStock.getTicker() + 264 ", but received: "+ aQueryResultLine 265 ); 266 } 267 268 BigDecimal roundedPrice = parsePrice(parser.nextToken()); 269 //BigDecimal methods return new objects: 270 roundedPrice = roundedPrice.setScale( 271 Consts.MONEY_DECIMAL_PLACES, ROUND_MODE 272 ); 273 274 //discard two tokens 275 parser.nextToken(); 276 parser.nextToken(); 277 278 BigDecimal roundedChange = parsePriceChange(parser.nextToken()); 279 roundedChange = roundedChange.setScale( 280 Consts.MONEY_DECIMAL_PLACES, Consts.MONEY_ROUNDING_STYLE 281 ); 282 283 return new Quote(aStock, roundedPrice, roundedChange); 284 } 285 286 287 /** 288 * Convert a price from text to a numeric value. 289 * If the price contains a fraction, change it to a decimal. 290 */ 291 private BigDecimal parsePrice(String aPrice) { 292 //Prices from Yahoo are in four forms 293 // 78 5/8 (characterized by space and slash) 294 // 78.625 295 // 5/8 296 // .01 297 BigDecimal result = ZERO; 298 BigDecimal dollars = ZERO; 299 BigDecimal numerator = ZERO; 300 BigDecimal denominator = ZERO; 301 aPrice = aPrice.trim(); 302 if (aPrice.indexOf('/') != -1) { 303 //the price contains a fraction somewhere 304 StringTokenizer parser = new StringTokenizer(aPrice); 305 while (parser.hasMoreElements()) { 306 String token = parser.nextToken(); 307 if (token.indexOf('/') == -1) { 308 //not the fractional part, so it must be the dollar part 309 dollars = new BigDecimal(token); 310 } 311 else { 312 //the fractional part; gets its 2 parts 313 StringTokenizer fractionParser = new StringTokenizer(token,"/"); 314 numerator = new BigDecimal(fractionParser.nextToken()); 315 denominator = new BigDecimal(fractionParser.nextToken()); 316 } 317 } 318 if (!denominator.equals(ZERO)){ 319 BigDecimal cents = 320 numerator.divide(denominator, ROUND_MODE).setScale(DECIMALS, ROUND_MODE) 321 ; 322 result = dollars.add(cents); 323 } 324 else { 325 result = dollars; 326 } 327 } 328 else { 329 //price doesn't contain any fraction 330 result = new BigDecimal(aPrice); 331 } 332 return result; 333 } 334 335 /** 336 * Convert a price change from text to a numeric value. 337 * If the price change contains a fraction, convert it to a decimal. 338 * Textual price changes are simply an algebraic sign plus a price. 339 */ 340 private BigDecimal parsePriceChange(String aPriceChange) { 341 //Price changes from Yahoo come in five forms: 342 // +5.25 343 // -5 1/4 344 // -1/4 345 // 0.00 (seen on holiday closures) 346 // 0 (seen on holiday closures) 347 BigDecimal result = ZERO; 348 BigDecimal sign = ZERO; 349 aPriceChange = aPriceChange.trim(); 350 //take the leading character as the sign. 351 if ( aPriceChange.startsWith(Consts.PLUS_SIGN) ) { 352 sign = new BigDecimal("1"); 353 } 354 else if ( aPriceChange.startsWith(Consts.NEGATIVE_SIGN) ) { 355 sign = new BigDecimal("-1"); 356 } 357 else { 358 //the price change is zero, and has no leading sign 359 return result; 360 } 361 //pass the string without the leading sign to parsePrice 362 String magnitudeOfPriceChange = aPriceChange.substring(1); 363 result = sign.multiply(parsePrice(magnitudeOfPriceChange)); 364 return result; 365 } 366 367 /** 368 * Remove all quotation marks. 369 * 370 * @param aTicker is of the form "JAVA", with leading and trailing quotation marks. 371 */ 372 private String parseTicker(String aTicker){ 373 return aTicker.replaceAll(Consts.DOUBLE_QUOTE, Consts.EMPTY_STRING); 374 } 375 376 /** 377 * Used for developing purposes only, to provide simple stub data. 378 * Uses the stocks in fStocks, but returns simple, fixed numbers for the quote. 379 */ 380 private List<Quote> getStaticQuotes(){ 381 java.util.List<Quote> result = new ArrayList<>(); 382 for(Stock stock : fStocks){ 383 Quote quote = new Quote(stock, new BigDecimal("10.00"), new BigDecimal("-0.75")); 384 result.add(quote ); 385 } 386 return result; 387 } 388}