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}