// // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the // Free Software Foundation, either version 3 of the License, or (at your // option) any later version. // // This program is distributed in the hope that it will be useful, but // *WITHOUT ANY WARRANTY*; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General // Public License for more details. // // You should have received a copy of the GNU General Public License along // with this program. If not, see . // Topic: Description // // Unique PHP implementation of stock charting. I couldn't find any *bar* // chart implementations in PHP, so ChartOHLC was born. See // for real-life examples of charts made // with this class. // Topic: Usage // // Couldn't be simpler, for my needs anyway. :-) // // (begin example) // // Let's define a nice clean colour scheme first: // $bgColor = 0xFFFFFF; // $gridColor = 0xE0E0E0; // $titleColor = 0x009000; // $upColor = $titleColor; // $downColor = false; // $borderColor = 0xFF8080; // $labelColor = 0x000000; // $volColor = 0x8080FF; // // // Now, build the chart where $data is our array: // require_once 'ChartOHLC.inc'; // $chart = new ChartOHLC(600, 300, $bgColor, $data); // $chart->title("MSFT - 5 min", $titleColor); // $chart->title("(C) 2008 imars.com but freely redistributable", $gridColor, true); // $chart->plotVolume($volColor, $borderColor, $labelColor, 50); // $chart->plotPriceLegends($borderColor, $labelColor, $gridColor, true); // $chart->plotPrice($upColor, $downColor); // // // Dump to stdout and destroy: // header('Content-Type: image/png'); // $chart->png(); // unset($chart); // (end example) // Topic: TODO // // The code has not been optimized at all yet, not even cleaned up. It just // does the job for now and I'll clean it up later on. // Undocumented properties private $x; private $y; private $img; private $colors; private $bgColor; private $data; private $dataLength; private $high; private $low; private $close; private $decimals; private $chartOriginX; private $chartOriginY; private $chartX; private $chartY; private $barWidth; private $titleWidth; // Group: Constants // Constant: LEGEND_HEIGHT // Minimum distance in pixels between vertical legend labels. const LEGEND_HEIGHT = 25; // Group: General Methods // Constructor: ChartOHLC // // Create a new OHLC chart. // // Parameters: // x - Width in pixels // y - Height in pixels // bgColor - Integer value for the background color. (i.e. "0xFFFFFF") // data - Indexed array of bar information. Each element is an // indexed array listing, in order: timestamp integer, open, // high, low, close price floats, and volume integer. // wantPrice - Bool whether you intend to plot a price legend. (Optional.) // wantVolume - Bool whether you intend to plot volume (mandatory legend.) // (Optional.) // decimals - Number of decimals to standardize to in price displays. // (Optional.) function ChartOHLC($x, $y, $bgColor, $data, $wantPrice = true, $wantVolume = true, $decimals = 2) { $this->x = $x; $this->y = $y; $this->img = imagecreate($x, $y); $this->colors = Array(); $this->bgColor = $bgColor; imagefill($this->img, 0, 0, $this->color($bgColor)); $this->data = &$data; $this->dataLength = count($data); $this->chartOriginX = 0; $this->chartOriginY = 0; $this->chartX = $x; $this->chartY = $y; $this->close = $data[$this->dataLength - 1][4]; $this->decimals = $decimals; $this->titleWidth = 0; $this->setBarWidth(4); // Reduce chart area to make room for legend. $this->chartX -= max( (strlen(number_format($this->high, $this->decimals, '.', ',')) * imagefontwidth(2)) + 6, (strlen(number_format($this->maxVol, 0, '.', ',')) * imagefontwidth(2)) + 4 ); // Chicken or the egg? We needed our high anx maxVol to find the legend // width, but then to calculate the final, viewable range we need // chartX. The least ugly solution is to call setBarWidth() before and // after reducing chartX. $this->setBarWidth(4); } // Destructor: __destruct // // Destroy this instance. function __destruct() { imagedestroy($this->img); } // Method: setBarWidth // // Change the density of the chart. By default, each bar is 4 pixels wide: // 1 pixel for the open dot, 1 pixel for the bar itself, 1 pixel for the // close dot, and 1 spacer. // // I personally prefer 3-6 depending on the purpose of the chart. 2-3 looks // very cramped, although it's cheaper than buying additional monitors. :-) // // Careful: // If you're changing the bar width with this method, be sure to call it // *before* invoking any of the plotting methods. // // Parameters: // width - New width in pixels. // // Returns: // The _width_ parameter. function setBarWidth($width) { // Gotta recalculate the high/low/maxVol/range for the viewable area only! $bars = min(ceil($this->chartX / $width),$this->dataLength); $last = $this->dataLength - 1; $this->high = $this->data[$last][2]; $this->low = $this->data[$last][3]; $this->maxVol = $this->data[$last][5]; for ($j=1, $i=$last; $j <= $bars; $j++, $i--) { $this->high = max($this->high, $this->data[$i][2]); $this->low = min($this->low, $this->data[$i][3]); $this->maxVol = max($this->maxVol, $this->data[$i][5]); }; $this->range = $this->high - $this->low; return $this->barWidth = $width; } // Method: getLow // // Get lowest price in the entire data set. // // If called after , it will be the viewable area *only*. // // If called after , it will be for the viewable area // only, but subject to the extending done to reach round numbers on the // legend. // // Returns: // The lowest price. function getLow() { return $this->low; } // Method: getHigh // // Get highest price in the entire data set. // // If called after , it will be the viewable area *only*. // // If called after , it will be for the viewable area // only, but subject to the extending done to reach round numbers on the // legend. // // Returns: // The highest price. function getHigh() { return $this->high; } // Method: png // // Generate PNG image. The result is either returned in a string or saved // to a file, depending on the presence of the _$filename_ argument. // // Parameters: // filename - File to save PNG image to. (Optional.) // // Returns: // The direct result of GD's imagepng(), which should be the PNG data in a // string if _$filename_ was omitted. function png($filename = null) { return imagepng($this->img, $filename, 9); } // Group: Plotting Methods // Method: title // // Add a title at the top of the chart. The first time it is invoked, it // reduces the height of the OHLC chart area in order to fit a line of text // at the top. In subsequent invocations, additional titles are appended at // the end of the existing title. // // Caveats: // // While multiple left-justified titles can be appended, only one // right-justified title can be printed. // // Parameters: // title - Text to display. // color - 24-bit colour integer to print with. (i.e. 0x00FF00 for green) // right - Bool whether you want the title right-justified. (Optional.) // // Returns: // No return value. function title($title, $color, $right = false) { // Reduce chart area to make room for title if this is the first. if ($this->titleWidth == 0) { $this->chartOriginY = 13; $this->chartY -= 13; }; // Draw title $newWidth = imagefontwidth(2) * strlen($title) + 3; if ($right) { imagestring($this->img, 2, $this->chartOriginX + $this->chartX - $newWidth, 0, $title, $this->color($color)); } else { imagestring($this->img, 2, $this->titleWidth + 3, 0, $title, $this->color($color)); $this->titleWidth += $newWidth; }; } // Method: plotPriceLegends // // Display legend labels and background grid. To stack your prices on top // of the grid, be sure to invoke this *before* . // // Parameters: // borderColor - 24-bit colour integer for the border // priceColor - 24-bit colour integer for the price // gridColor - 24-bit colour integer for the grid or _false_ if // undesired. (Optional.) // doTime - Bool whether to also process the X (time) axis. // (Optional.) // mGridColor - 24-bit colour integer for the major grid lines or _false_ // if undesired. (Optional.) // // Returns: // No return value. function plotPriceLegends($borderColor, $priceColor, $gridColor = false, $doTime = true, $mGridColor = false) { // CAUTION: Modifies $this->high, $this->low, $this->range to improve legibility. if ($doTime) $this->chartY -= 26; // Draw legend numbers $steps = max(floor($this->chartY / ChartOHLC::LEGEND_HEIGHT), 2); $stepPrice = (($this->high - $this->low) / $steps); // Lock $stepPrice to closest round interval. // This is a quick hack tailored to my preferences. There may be a way // to make this universal with a one-liner (via a string in exponential // notation perhaps?) // FOREX intervals: if ($stepPrice < 0.0005) $stepPrice = 0.0005; elseif ($stepPrice < 0.0010) $stepPrice = 0.0010; elseif ($stepPrice < 0.0025) $stepPrice = 0.0025; elseif ($stepPrice < 0.0050) $stepPrice = 0.0050; elseif ($stepPrice < 0.0100) $stepPrice = 0.0100; // Stock intervals: elseif ($stepPrice < 0.05) $stepPrice = 0.05; elseif ($stepPrice < 0.10) $stepPrice = 0.1; elseif ($stepPrice < 0.25) $stepPrice = 0.25; elseif ($stepPrice < 0.50) $stepPrice = 0.5; elseif ($stepPrice < 1) $stepPrice = 1; elseif ($stepPrice < 2) $stepPrice = 2; elseif ($stepPrice < 5) $stepPrice = 5; // Stock/Index intervals: elseif ($stepPrice < 10) $stepPrice = 10; elseif ($stepPrice < 25) $stepPrice = 25; elseif ($stepPrice < 50) $stepPrice = 50; elseif ($stepPrice < 100) $stepPrice = 100; elseif ($stepPrice < 250) $stepPrice = 250; elseif ($stepPrice < 500) $stepPrice = 500; elseif ($stepPrice < 1000) $stepPrice = 1000; $this->low = floor($this->low / $stepPrice) * $stepPrice; $this->high = ceil($this->high / $stepPrice) * $stepPrice; $this->range = $this->high - $this->low; $steps = floor(($this->high - $this->low) / $stepPrice); $gridCol = ($gridColor !== false) ? $this->color($gridColor) : false; $mGridCol = ($mGridColor !== false) ? $this->color($mGridColor) : $gridCol; $fontVOffset = round(imagefontheight(2) / 2); for ($i=0, $j=$steps; $j>0; $i++, $j--) { $level = $this->low + ($j * $stepPrice); $label = number_format($level, $this->decimals, '.', ','); $y = $this->priceY($level); imagestring($this->img, 2, $this->chartOriginX + $this->chartX + 5, $y - $fontVOffset, "{$label}", $this->color($priceColor)); imageline($this->img, $this->chartOriginX + $this->chartX, $y, $this->chartOriginX + $this->chartX + 3, $y, $this->color($borderColor)); if ($gridCol !== false) { imageline($this->img, $this->chartOriginX, $y, $this->chartOriginX + $this->chartX, $y, $gridCol); }; }; if ($doTime) { // We have room for bottom line label. $label = number_format($this->low, $this->decimals, '.', ','); $x = $this->chartOriginX + $this->chartX; $y = $this->chartOriginY + $this->chartY; imagestring($this->img, 2, $x + 5, $y - $fontVOffset, "{$label}", $this->color($priceColor)); imageline($this->img, $x, $y, $x + 3, $y, $this->color($borderColor)); }; // Draw border imageline($this->img, $this->chartX, $this->chartOriginY - $fontVOffset, $this->chartX, $this->chartOriginY + $this->chartY, $this->color($borderColor)); // Take care of time axis now... if ($doTime) { $top = $this->chartOriginY + $this->chartY; $right = $this->chartOriginX + $this->chartX; // Draw top border imageline($this->img, $this->chartOriginX, $top, $right, $top, $this->color($borderColor)); // Erase right border imageline($this->img, $right, $top + 1, $right, $top + 26, $this->color($this->bgColor)); $bars = min(floor($this->chartX / $this->barWidth), $this->dataLength); // Guess interval: smallest of the last two, in case the last one // happens to be a gap. $interval = min(($this->data[$this->dataLength - 1][0] - $this->data[$this->dataLength - 2][0]), ($this->data[$this->dataLength - 2][0] - $this->data[$this->dataLength - 3][0])); if ($mGridCol !== false) { $G = $mGridCol; $T = IMG_COLOR_TRANSPARENT; imagesetstyle($this->img, Array($G, $G, $G, $G, $G, $G, $G, $G, $G, $T, $T, $T, $G, $G, $G, $T, $T, $T, $G, $G, $G, $T, $T, $T)); }; $lastDate = getdate($this->data[$this->dataLength - 1][0]); $lastMajorX = $right; $lastMinorX = $right; for($j=1, $i=$this->dataLength - 1; $j <= $bars; $i--, $j++) { $x = $this->chartOriginX + $this->chartX - ($j * $this->barWidth) - 2; $prevDate = getdate($this->data[$i - 1][0]); $isMajor = false; $isMinor = false; $label = '???'; if ($interval <= 600) { // Interval: 10 min or less // Major: day changes // Minor: hour changes if ($lastDate['mday'] != $prevDate['mday']) { $isMajor = true; $label = date('M j', $this->data[$i][0]); } elseif ($lastDate['hours'] != $prevDate['hours']) { $isMinor = true; $label = date('G:i', $this->data[$i][0]); }; } elseif ($interval <= 7200) { // Interval: 10 min to 2 hours // Major: month changes // Minor: day changes if ($lastDate['mon'] != $prevDate['mon']) { $isMajor = true; $label = date('M', $this->data[$i][0]); } elseif ($lastDate['mday'] != $prevDate['mday']) { $isMinor = true; $label = date('j', $this->data[$i][0]); }; } elseif ($interval <= 86400) { // Interval: 2 hours to daily // Major: month changes (identify year in label) if ($lastDate['mon'] != $prevDate['mon']) { $isMajor = true; $label = date('M Y', $this->data[$i][0]); }; } else { // Interval: multiple days or more // Major: year changes // Minor: month changes if ($lastDate['year'] != $prevDate['year']) { $isMajor = true; $label = date('Y', $this->data[$i][0]); } elseif ($lastDate['mon'] != $prevDate['mon']) { $isMinor = true; $label = date('M', $this->data[$i][0]); }; }; $lastDate = $prevDate; if ($isMajor) { if ($x + (strlen($label) * imagefontwidth(2)) < $lastMajorX) { if ($gridCol !== false) imageline($this->img, $x, $this->chartOriginY, $x, $top, IMG_COLOR_STYLED); imageline($this->img, $x, $top, $x, $top + 6, $this->color($borderColor)); imagestring($this->img, 2, $x + 2, $top + 13, $label, $this->color($priceColor)); $lastMajorX = $x; }; } elseif ($isMinor) { if ($x + (strlen($label) * imagefontwidth(2)) < $lastMinorX) { if ($gridCol !== false) imageline($this->img, $x, $this->chartOriginY, $x, $top, $gridCol); imageline($this->img, $x, $top, $x, $top + 2, $this->color($borderColor)); imagestring($this->img, 2, $x + 2, $top + 2, $label, $this->color($priceColor)); $lastMinorX = $x; }; }; }; }; } // Method: plotPrice // // Plot OHLC price bars. Omitting a down colour draws all bars in the same // colour. // // Parameters: // // upColor - 24-bit colour integer // downColor - 24-bit colour integer (Optional.) // // Returns: // No return value. function plotPrice($upColor, $downColor = false) { $upCol = $this->color($upColor); $downCol = ($downColor !== false) ? $this->color($downColor) : $upCol; $bars = min(floor($this->chartX / $this->barWidth), $this->dataLength); for($j=1, $i=$this->dataLength - 1; $j <= $bars; $i--, $j++) { $col = ($this->data[$i][1] > $this->data[$i][4]) ? $downCol : $upCol; $x = $this->chartOriginX + $this->chartX - ($j * $this->barWidth); imagesetpixel($this->img, $x-1, $this->priceY($this->data[$i][1]), $col); // Open pixel imageline($this->img, $x, $this->priceY($this->data[$i][2]), $x, $this->priceY($this->data[$i][3]), $col); // High-Low bar imagesetpixel($this->img, $x+1, $this->priceY($this->data[$i][4]), $col); // Close pixel }; } // Method: plotVolume // // Create and plot a volume histogram area. // // Caveats: // It is necessary to do this *before* invoking and // because it reduces the height of the OHLC chart area. // // Parameters: // color - 24-bit colour integer for the histogram. // borderColor - 24-bit colour integer for the border. // titleColor - 24-bit colour integer for the inline "Volume" title. // height - Height in pixels. function plotVolume($color, $borderColor, $titleColor, $height) { $borderCol = $this->color($borderColor); $col = $this->color($color); $titleCol = $this->color($titleColor); $this->chartY -= $height; // Make room for volume at the bottom // Draw borders imageline($this->img, $this->chartOriginX, $this->chartOriginY + $this->chartY + 1, $this->x, $this->chartOriginY + $this->chartY + 1, $borderCol); imageline($this->img, $this->chartOriginX + $this->chartX, $this->chartOriginY + $this->chartY, $this->chartOriginX + $this->chartX, $this->y, $borderCol); // Draw legend imagestring($this->img, 2, $this->chartOriginX + $this->chartX + 3, $this->chartOriginY + $this->chartY + 1, number_format($this->maxVol, 0, '.', ','), $titleCol); imagestring($this->img, 2, $this->chartOriginX + $this->chartX + 3, $this->y - 13, "0", $titleCol); // Draw volume histogram $bars = min(floor($this->chartX / $this->barWidth), $this->dataLength); for ($j=1, $i=$this->dataLength - 1; $j <= $bars; $i--, $j++) { $x = $this->chartOriginX + $this->chartX - ($j * $this->barWidth); $y = $this->y - ($this->data[$i][5] / $this->maxVol * ($height - 1)) + 1; imagefilledrectangle($this->img, $x-1, $y, $x + $this->barWidth - 3, $this->chartOriginY + $this->y, $col); }; // Draw title imagestring($this->img, 2, $this->chartOriginX + 3, $this->chartOriginY + $this->chartY + 1, "Volume", $titleCol); } // Method: plotTrend // // Draw a trend line. The line starts at the origin coordinates, then // becomes dashed after the second coordinates. // // Caveats: // // The coordinates are relative to the *entire image* not just the price // area. This is to help users (such as myself) with external sources of // coordinates. (In my case, JavaScript mouse click events.) // // Parameters: // x1 - X of origin pixel // y1 - Y of origin pixel // x2 - X of second pixel // y2 - Y of second pixel // color - 24-bit colour integer for the line // // Returns: // No return value. function plotTrend($x1, $y1, $x2, $y2, $color) { // Don't tolerate vertical lines. if ($x1 == $x2) return; $brush = imagecreate(3,3); imagefill($brush, 0, 0, imagecolorallocate($brush, ($color >> 16) & 0xFF, ($color >> 8) & 0xFF, $color & 0xFF)); imagesetbrush($this->img, $brush); imageline($this->img, $x1, $y1, $x2, $y2, IMG_COLOR_BRUSHED); $G = $this->color($color); $T = IMG_COLOR_TRANSPARENT; imagesetstyle($this->img, Array($T, $T, $T, $T, $T, $T, $G, $G, $G, $G, $G, $G, $G, $G, $G, $G)); $x3 = $this->chartOriginX + $this->chartX; $y3 = $y2; if ($y1 != $y2) { $y3 = ($y1 == $y2) ? $y1 : floor((($y2 - $y1)/($x2 - $x1))*($x3 - $x1)) + $y1; $y4 = $this->chartOriginY + (($y2 - $y1) > 0 ? $this->chartY : 0); $x4 = ($x1 == $x2) ? $x1 : floor((($x2 - $x1)/($y2 - $y1))*($y4 - $y1)) + $x1; // Display the line which will be shortest if (pow($x3 - $x2, 2) + pow($y3 - $y2, 2) > pow($x4 - $x2, 2) + pow($y4 - $y2, 2)) { $x3 = $x4; $y3 = $y4; }; }; imageline($this->img, $x2, $y2, $x3, $y3, IMG_COLOR_STYLEDBRUSHED); imagedestroy($brush); } // Method: plotPriceStudy // // Draws a broken line. The array of data is expected to have exactly as // many elements as the bars passed to at creation time. Each // value in the array must be in the displayable price range, or it will be // omitted. // // Caveats: // // The displayable price range will *not* be stretched to accomodate values // outside of it, as it was determined in which should // be called before other plotting methods including this one. // // Parameters: // data - Indexed array of price values to plot. // color - 24-bit colour integer for the broken line. // smooth - Smoothing: 0=none, 1=mild, 2=more. (Optional.) // // Returns: // No return value. function plotPriceStudy($data, $color, $smooth = 0) { $col = $this->color($color); $bars = min(floor($this->chartX / $this->barWidth), $this->dataLength); $xp = false; $yp = false; $ypp = false; for($j=1, $i=$this->dataLength - $bars; $j <= $bars; $i++, $j++) { $y = $this->priceY($data[$i]); if ($smooth && $xp && $yp) { if ($ypp && $j < $bars - 1 && $smooth > 1) $y = round(($ypp + $yp + $y + $this->priceY($data[$i+1]) + $this->priceY($data[$i+2])) / 5); elseif ($j < $bars) $y = round(($yp + $y + $this->priceY($data[$i+1])) / 3); else $y = round(($yp + $y) / 2); }; if ($y >= $this->chartOriginY && $y <= $this->chartOriginY + $this->chartY) { $x = $this->chartOriginX + $this->chartX - (($bars - $j) * $this->barWidth) - 1; if ($xp && $yp) { // imagesetpixel($this->img, $x, $y, $col); imageline($this->img, $xp, $yp, $x, $y, $col); }; $xp = $x; $ypp = $yp; $yp = $y; } else { $xp = false; $yp = false; }; }; } // Function: plotLevelStudy // // FIXME // function plotLevelStudy($levels, $color) { foreach ($levels as $level) { $y = $this->priceY($level[0]); imageline($this->img, $this->chartOriginX, $y, $this->chartOriginX + $this->chartX, $y, $this->color($color, abs($level[1] / 30))); }; } // Private Group: Private Methods // Private Method: color // // Resolve a colour integer into a GD colour index. The integer's least // significant 24 bits are used as a sequence of 8-bit R, G and B values. // (i.e. 0xFFFFFF is white) If the colour was already referenced for the // image, its previous index is returned to make absolutely certain that // we're using the same palette index for a same colour. // // If supplied, the intensity between 0 and 1 is compared to the chart // background. Note that 0 actually means 25% and 1 means 100% for clarity. // // Parameters: // num - 24-bit integer colour representation. (i.e. 0xFF0000) // alpha - Float 0..1 of intended intensity from chart background. // (Optional.) // // Returns: // The appropriate GD colour index. private function color($num, $alpha = null) { $r = ($num >> 16) & 0xFF; $g = ($num >> 8) & 0xFF; $b = $num & 0xFF; if ($alpha) { if ($alpha < 0) $alpha = 0; elseif ($alpha > 1) $alpha = 1; $alpha = 0.25 + ($alpha * 0.75); $br = ($this->bgColor >> 16) & 0xFF; $bg = ($this->bgColor >> 8) & 0xFF; $bb = $this->bgColor & 0xFF; $r = (($r - $br) * $alpha) + $br; $g = (($g - $bg) * $alpha) + $bg; $b = (($b - $bb) * $alpha) + $bb; }; if (!$alpha && isset($this->colors[$num])) { return $this->colors[$num]; } else { return $this->colors[$num] = imagecolorallocate($this->img, $r, $g, $b); }; } // Private Method: priceY // // Resolve a price into a vertical position. // // Caveats: // *NO LIMITS ARE ENFORCED* so it is entirely possible to obtain a negative // or otherwise useless position. // // Parameters: // price - Price to position. // // Returns: // The equivalent vertical position in the OHLC chart window. private function priceY($price) { return round($this->chartOriginY + $this->chartY - ((($price - $this->low) / $this->range) * $this->chartY)); } } ?>