ORIGINAL DRAFT
It’s hard not to be sensitive to the stock market this year. It seems appropriate, therefore, that we take the time to implement a simple chart component that handles stock history information. Traditional charts show daily high and low stock prices, along with the open and close prices. A second chart shows the volume of shares traded on each day. We’ll provide both these charts and use a table model that handles stock information you can freely download from the Yahoo financial web site.
Let’s briefly cover how you can get compatible stock data. If you visit http://chart.yahoo.com/d, you’ll see something that looks like Figure 2. From there, you can pick a stock, IBM for example, and get the Daily quotes. Don’t forget to select a suitable range of dates, typically at least a year of daily quotes.
When you press the "Get Historical Data" button, you’ll get an HTML page with data represented in a tabular format. At the bottom of that page is a link called "Download Spreadsheet", which lets you save the information in a CSV (Comma Separated Value) file. This is the format we’ll use in our model.
By convention, I’ve named CSV files with the ticker symbol and a ".CSV" extension. The test program for JStock makes this assumption, so you’ll want to do the same thing, at least to make sure this works, putting data files in the same directory as the class files to run JStockTest. Naming them by the ticker symbol makes it easy to ensure you have unique and suitably descriptive file names.
The comma-separated files include date, open, high, low, close and volume fields. We’ll implement a TableModel that uses the same column order and declare some constants (static final variables) that allow us to retrieve these values more easily. Because we use a TableModel, we can view this data as either a JTable or in the form of stock charts with JStock.
The classes in the JStock project are diagrammed in Figure 3. The JStock class provides a view that includes both the Price and Volume charts, both of which display charts based on the StockModel provided. The StockModel class provides a parseFile method that lets you read CSV files at your discretion. The StockChart class is abstract and provides most of the basic chart functionality inherited by StockPriceChart and StockVolumeChart. The later two classes provide more specific functionality.
We only have room to look at a few listings, but you can find the all the classes online at www.javapro.com. The JStock class itself merely places StockPriceChart and StockVolumeChart instances in a BorderLayout, so we can safely skip over it without loosing much. The StockModel is also quite simple, storing each line of data as a set of values in a list, each of which is held in a list of rows.
The only two methods of interest are parseFile and parseLine, which read the CSV file into ArrayList instances. The TableModel methods are relatively straight forward. You’ll find it easy to decipher the code, so we won’t cover them in detail. We’ll put our focus on the chart classes themselves; StockChart, StockPriceChart and StockVolumeChart. That’s where most of the interesting work takes place.
Listing 1 shows the code for StockChart. At the top of the class are a few declarations; a static array of month labels, along with instance variables for the inside Insets, the StockModel, and a few values we’ll need along the way for hi, lo data values, min, max chart values, as well as the chart data range, number of divisions on the vertical axis and how large each unit is. These values are initialized in the constructor and used to draw chart elements.
Each chart is divided into five areas. The central area with grid line and plotted values, the top label, bottom X axis and Y axis labels on both the left and right. One tricky operation is figuring out how to label the Y axis values. To do this, we initialize a few variables. To set the hi and lo value we call calculateMinMax, which is an abstract method implemented by subclasses.
The range value is initialized by calling the calculateRange method, which figures out the widest range between the hi and lo values. You’ll notice that we use the floor of the low value and ceiling of the high value to round down and up, respectively. The unit value is the size of each range of data between grid lines. We initialize the variable by calling calculateUnit, which returns an integer based on the size of the range. Since values may range from volumes in the millions or prices in the tens or hundreds, we handle a broad range of possibilities. Note that calculateUnit uses a local range value, a double, to avoid rounding errors.
With the range and unit values all set, the divs (divisions) value is easy to calculate. We want to know how many divisions there are on the chart. We add two to account for values below or above the range. The min value is the floor of the lowest value divided by the unit size and remultiplied to units. In other words, we round down to the best lowest unit value for the bottom of the chart. The max value is then the number of divisions multiplied by the unit size, with the min value added to push values up from the bottom of the chart.
One of the operations we’ll do often is normalize the plotted values to fit within the display range. The normalize method makes this easy, calculating the result for a given chartable value, accounting for the height of the display area. We provide accessors for the hi and lo values, which are either the price or volume minimum and maximum values. These are reported by the label at the top of the chart.
The next set of methods abstract access to the model to make it possible to get values without long calls or casting. The getValue method is called by the others and implicitly addresses the current model. The getDate, getOpen, getHigh, getLow, getClose and getVolume methods each return appropriate values. A Date object for getDate, a long value for getVolume and a double for the rest.
The drawText method lets us draw text in a given rectangle and justify it on the left, right or centered. We use the SwingConstants interface to set the state for justification. The next pair of methods handle the horizontal axis. The names may be a little confusing because the drawHorzGridLines draws lines for the horizontal grid rather than horizontal lines. It all makes sense if you think about it.
Horizontal lines and labels are plotted at the first of the month intervals, so the code walks the Date field and watches for month changes using the month and next variables. We can’t count on the first of the month being a trading/weekday, so we need to account for that. The month label is fetched from the months array. The drawHorzGridLines adds two lines at the top and bottom of the grid to frame the region. Otherwise, it’s all pretty straight forward.
Drawing vertical grid lines and labels depends on the range, min, max, divs and unit values calculated in the constructor. The vertical axis lines are drawn at each unit interval, scaled to the height of the drawing area. The same is true of the labels, though we delegate formatting to an abstract formatLabel method implemented by subclasses. Labels are justified, based on whether they’re on the right or left of the chart.
You’ll see all the abstract method declarations a the bottom of Listing 1. The only one we didn’t mention is the drawChartValues method, which actually plots the values in the drawing area at the center of the chart, surrounded by labels on all four sides. This is where we use the Insets set in the constructor. The inside variable is used to avoid conflicts with the standard insets used by the border classes. Because the StockChart subclasses are only used in the JStock class, I’ve made no attempt to support standard borders.
Listing 2 shows the StockPriceChart class. We subclass StockChart and implement the three mandatory abstract methods, but the class is otherwise uncomplicated. We use a DecimalFormat instance to format the high and low values displayed in the top label. The constructor initializes the insets and sets a preferred size. The preferred size is three times the number of data points because we need three pixels to paint the ‘candlesticks’ in the price chart. We add the inset values to account for them in the preferred size.
The calculateMinMax method is simple. It captures the hi and lo values by checking for smaller an larger values, initializing to the highest and lowest possible values to ensure we pick up the differences. After the loop is executed through each data point, the hi and lo variables are properly set.
The formatLabel method is used by the drawVertAxisLabels method in the superclass and does little in the StockPriceChart other than return a string with the price value. Note that the value is rounded to an integer by the superclass, so we don’t need to handle any decimal points in these labels. The drawTopLabel method uses a StringBuffer to format the label at the top of the display area, which includes the hi and lo values, formatted by our DecimalFormat instance.
The paintComponent method delegates most of the drawing to the methods declared in the abstract superclass. I’ve left paintComponent in the subclasses to support more customization, though the two chart subclasses have pretty much identical code in this method. The order in which we draw the elements is largely arbitrary because there is very little overlap to watch for, other than a need to draw the grid lines before the data.
The drawChartValues method does the actual plotting, accounting the normalized values that scale to the drawing area. The ‘candlestick’ regions include a thin high-low line in the middle and a thicker open-close line in the middle. This lets you see the stock’s price range for a given day at a glance. Otherwise, there’s not much to it.
Listing 3 shows the code for StockVolumeChart, which is similar to Listing 2 in structure and content. The DecimalFormat is configured to handle comma-delimited volume values, which tend to be in the millions and are easier to read this way. The same initialization takes place in the constructor, with the preferred height set to a smaller value. The calculateMinMax calls getVolume instead of getHigh and getLow.
The formatLabel method handles the axis labels and represents values in millions, using an ‘M’ suffix. The drawTopLabel method shows hi and lo values in the same way as it’s twin in the StockPriceChart class, handling Volume values instead of Price. I won’t cover paintComponent. As I mentioned, this is virtually identical to the StockPriceChart implementation. The drawChartValues method handles plotting volume as solid lines from the bottom. These are two pixels wide, with a blank pixel to the right.
There’s nothing overly complicated in JStock, though this implementation illustrates some useful techniques, including abstraction, good separation of responsibility and effective use of a data model. If you follow the stock market, you might feel inclined to plot your favorite stocks using this component, or a variation on the same theme. Sophisticated traders are used to much more comprehensive analysis tools, but sometimes a simple component is all your users really need.
Listing 1
import java.awt.*;
import java.util.*;
import javax.swing.*;
public abstract class StockChart extends JPanel
implements SwingConstants
{
protected static final String[] months =
{
"J", "F", "M", "A", "M", "J",
"J", "A", "S", "O", "N", "D"
};
protected Insets inside;
protected StockModel model;
protected double hi, lo;
protected double min, max;
protected int range, divs, unit;
public StockChart(StockModel model)
{
this.model = model;
calculateMinMax();
range = calculateRange();
unit = calculateUnit();
divs = (int)(range / unit) + 2;
min = (int)Math.floor(lo / unit) * unit;
max = divs * unit + min;
}
protected int calculateRange()
{
return (int)(Math.ceil(hi) - Math.floor(lo));
}
protected int calculateUnit()
{
double range = calculateRange();
if (range > 10000000) return 10000000;
if (range > 1000000) return 1000000;
if (range > 100000) return 100000;
if (range > 10000) return 10000;
if (range > 1000) return 1000;
if (range > 100) return 100;
if (range > 10) return 10;
return 1;
}
protected int normalize(double value, double height)
{
double range = max - min;
double factor = ((value - min) / range);
return (int)(height - factor * height);
}
public double getHigh()
{
return hi;
}
public double getLow()
{
return lo;
}
protected Object getValue(int row, int col)
{
return model.getValueAt(row, col);
}
public Date getDate(int row)
{
return (Date)getValue(row, StockModel.DATE);
}
public double getOpen(int row)
{
Object val = getValue(row, StockModel.OPEN);
return ((Double)val).doubleValue();
}
public double getHigh(int row)
{
Object val = getValue(row, StockModel.HIGH);
return ((Double)val).doubleValue();
}
public double getLow(int row)
{
Object val = getValue(row, StockModel.LOW);
return ((Double)val).doubleValue();
}
public double getClose(int row)
{
Object val = getValue(row, StockModel.CLOSE);
return ((Double)val).doubleValue();
}
public long getVolume(int row)
{
Object val = getValue(row, StockModel.VOLUME);
return ((Long)val).longValue();
}
protected void drawText(
Graphics g, int x, int y, int w, int h,
String text, int justify)
{
FontMetrics metrics = g.getFontMetrics();
int width = metrics.stringWidth(text);
if (justify == LEFT) x += 3;
if (justify == CENTER) x += (w - width) / 2;
if (justify == RIGHT) x += w - width - 3;
y += (h / 2) - (metrics.getHeight() / 2) + metrics.getAscent();
g.drawString(text, x, y);
}
public void drawHorzGridLines(
Graphics g, int x, int y, int w, int h)
{
int count = model.getRowCount();
Calendar calendar = Calendar.getInstance();
calendar.setTime(getDate(count - 1));
int month = calendar.get(Calendar.MONTH);
for(int i = count - 1; i >= 0; i--)
{
calendar.setTime(getDate(count - i - 1));
int next = calendar.get(Calendar.MONTH);
if (month != next)
{
month = next;
g.drawLine(x + i * 3, y, x + i * 3, y + h);
}
}
g.drawLine(x, y, x, y + h);
g.drawLine(x + w, y, x + w, y + h);
}
public void drawHorzAxisLabels(
Graphics g, int x, int y, int w, int h)
{
int count = model.getRowCount();
Calendar calendar = Calendar.getInstance();
calendar.setTime(getDate(count - 1));
int month = calendar.get(Calendar.MONTH);
for(int i = count - 1; i >= 0; i--)
{
calendar.setTime(getDate(count - i - 1));
int next = calendar.get(Calendar.MONTH);
if (month != next)
{
month = next;
String label = months[month];
if (month == 0)
{
label = "" + calendar.get(Calendar.YEAR);
label = label.substring(label.length() - 2);
}
drawText(g, x + i * 3 - 15,
y, 30, h, label, CENTER);
}
}
}
public void drawVertGridLines(
Graphics g, int x, int y, int w, int h)
{
int incr = (int)(h / divs);
for(int i = 0; i < h; i += incr)
{
g.drawLine(x, y + h - i, x + w, y + h - i);
}
g.drawLine(x, y, x + w, y);
g.drawLine(x, y + h, x + w, y + h);
}
protected void drawVertAxisLabels(
Graphics g, int x, int y, int w, int h, int justify)
{
int incr = (int)(h / divs);
int count = 0;
for(int i = 0; i < h; i += incr)
{
drawText(g, x, y + h - i - 10, w, 20,
formatLabel((int)(count * unit + min)),
justify);
count++;
}
}
protected abstract String formatLabel(int value);
protected abstract void calculateMinMax();
protected abstract void drawChartValues(
Graphics g, int x, int y, int w, int h);
}
Listing 2
import java.awt.*;
import java.text.*;
import javax.swing.*;
public class StockPriceChart extends StockChart
{
protected static final DecimalFormat form =
new DecimalFormat("$###.##");
public StockPriceChart(StockModel model)
{
super(model);
setBackground(Color.white);
int count = model.getRowCount();
inside = new Insets(20, 30, 20, 30);
setPreferredSize(new Dimension(
count * 3 + (inside.left + inside.right), 280));
}
protected void calculateMinMax()
{
hi = Double.MIN_VALUE;
lo = Double.MAX_VALUE;
int count = model.getRowCount();
for(int i = 0; i < count; i++)
{
double high = getHigh(i);
double low = getLow(i);
if (high > hi) hi = high;
if (low < lo) lo = low;
}
}
protected String formatLabel(int value)
{
return "" + value;
}
protected void drawTopLabel(
Graphics g, int x, int y, int w, int h)
{
StringBuffer buffer = new StringBuffer();
buffer.append("Price (");
buffer.append("low = ");
buffer.append(form.format(lo));
buffer.append(", high = ");
buffer.append(form.format(hi));
buffer.append(")");
String text = buffer.toString();
drawText(g, x, y, w, h, text, LEFT);
}
public void paintComponent(Graphics g)
{
int x = 0;
int y = 0;
int w = getSize().width;
int h = getSize().height;
g.setColor(getBackground());
g.fillRect(0, 0, w, h);
x += inside.left;
y += inside.top;
w -= (inside.left + inside.right);
h -= (inside.top + inside.bottom);
g.setColor(Color.gray);
drawHorzGridLines(g, x, y, w, h);
drawVertGridLines(g, x, y, w, h);
drawChartValues(g, x, y, w, h);
g.setColor(getForeground());
drawTopLabel(g, x, 0, x + w, y);
drawHorzAxisLabels(g, x, y + h, w, inside.bottom);
drawVertAxisLabels(g, 0, y, x, h, RIGHT);
drawVertAxisLabels(g, x + w, y,
inside.right, h, LEFT);
}
protected void drawChartValues(
Graphics g, int x, int y, int w, int h)
{
int count = model.getRowCount();
for(int i = count - 1; i >= 0; i--)
{
int xx = x + i * 3;
int y1 = y + normalize(getHigh(i), h);
int y2 = y + normalize(getLow(i), h);
int y3 = y + normalize(getOpen(i), h);
int y4 = y + normalize(getClose(i), h);
g.setColor(Color.blue);
g.drawLine(xx + 1, y1, xx + 1, y2);
g.setColor(Color.blue);
g.drawLine(xx, y3, xx, y4);
g.drawLine(xx + 1, y3, xx + 1, y4);
g.drawLine(xx + 2, y3, xx + 2, y4);
}
}
}
Listing 3
import java.awt.*;
import java.text.*;
import javax.swing.*;
public class StockVolumeChart extends StockChart
{
protected static final DecimalFormat form =
new DecimalFormat("#,###,###,###");
public StockVolumeChart(StockModel model)
{
super(model);
setBackground(Color.white);
int count = model.getRowCount();
inside = new Insets(20, 30, 20, 30);
setPreferredSize(new Dimension(
count * 3 + (inside.left + inside.right), 120));
}
protected void calculateMinMax()
{
hi = Long.MIN_VALUE;
lo = Long.MAX_VALUE;
int count = model.getRowCount();
for(int i = count - 1; i >= 0; i--)
{
long vol = getVolume(i);
if (vol > hi) hi = vol;
if (vol < lo) lo = vol;
}
}
protected String formatLabel(int value)
{
return "" + (value / 1000000) + "M";
}
protected void drawTopLabel(
Graphics g, int x, int y, int w, int h)
{
StringBuffer buffer = new StringBuffer();
buffer.append("Volume (");
buffer.append("low = ");
buffer.append(form.format(lo));
buffer.append(", high = ");
buffer.append(form.format(hi));
buffer.append(")");
String text = buffer.toString();
drawText(g, x, y, w, h, text, LEFT);
}
public void paintComponent(Graphics g)
{
int x = 0;
int y = 0;
int w = getSize().width;
int h = getSize().height;
g.setColor(getBackground());
g.fillRect(x, y, w, h);
x += inside.left;
y += inside.top;
w -= (inside.left + inside.right);
h -= (inside.top + inside.bottom);
g.setColor(Color.gray);
drawHorzGridLines(g, x, y, w, h);
drawVertGridLines(g, x, y, w, h);
drawChartValues(g, x, y, w, h);
g.setColor(getForeground());
drawTopLabel(g, x, 0, x + w, y);
drawHorzAxisLabels(g, x, y + h, w, inside.bottom);
drawVertAxisLabels(g, 0, y, x, h, RIGHT);
drawVertAxisLabels(g, x + w, y, inside.right, h, LEFT);
}
protected void drawChartValues(
Graphics g, int x, int y, int w, int h)
{
g.setColor(Color.blue);
int count = model.getRowCount();
for(int i = count - 1; i >= 0; i--)
{
int xx = x + i * 3;
int yy = y + normalize(getVolume(i), h);
g.drawLine(xx, y + h, xx, yy);
g.drawLine(xx + 1, y + h, xx + 1, yy);
}
}
}