从同花顺软件中抓取上证指数成交明细

前段时间迷恋华泰证券的猜涨跌游戏,也就是在每个交易日下午1点前下注,押今天上证指数收盘是涨还是跌。本来想做数据挖掘的,但是最近的一些事情让我觉得还是金盆洗手,不再碰金融了。

要做数据挖掘,首先就是要有数据咯。于是我想到,最细的数据就是成交明细了。可是找遍了新浪财经、腾讯财经、和讯财经等网站,都没有找到哪个页面可以查看上证指数的成交明细的。后来发现可以在同花顺软件中查看任何一天的成交明细,如图:

只需要在日K线图上双击一下就能出来这一天的成交明细。

OK,数据源有了,可是这里面的数据怎么导出来呢?又不能复制粘贴,又没有现成的选项导出。

那就用暴力手段啦——OCR!既然能够给人看,那么计算机就应该能够通过屏幕读词识别的方式把数据都弄下来。于是我就找Java有没有现成的OCR库。别说,还真有,貌似最流行的就是Tess4j。于是我就用Tess4j试试,结果效果很不理想。Tess4j会把图片中所有的“3”都识别为”8″。这个我可不能忍啊!

于是我只能自己实现一个针对同花顺的OCR程序了。同花顺的成交明细里面,无非就是14个字符,分别是阿拉伯数字的0~9,表示时间的“:”、表示小数点的“.”、表示数据不详的“-”还有表示分割的空格。而且开心的是,出现在不同地方的同一个字符都是像素级别地相同。那就好办啦!

==================阶段一:基本原理=================

首先肯定是屏幕截图。这个用Java做很容易,不详解,接下来的代码中有。

假设现在已经截取了窗口中的所有成交明细,如图:

那么接下来就是二值化。所谓二值化,就是把所有字符都变成白色,而把背景都变成黑色。从图中可以看出,字符部分无非就是四种颜色,即——灰色、红色、绿色和蓝色。不过事实上共5种颜色,还有一种是白色,当价格为平盘的时候使用这种颜色。经过取色得到这五种颜色具体的RGB值分别是:灰色(#C0C0C0)、红色(#FF3232)、绿色(#00E600)、蓝色(#02E2F4)和白色(#FFFFFF)。那么就挨个像素遍历,如果某个像素是这五种颜色中的一种,那么就把该像素点设置为白色,否则就设置为黑色。

上面的图片经过二值化处理后的图片就成了这样:

接下来,就是要分割行。将图片分割成一行一行,那么就更加容易处理。分割的算法其实也很简单。我定义一个变量y,表示图片的y坐标。然后假想在纵坐标为y的地方有一条水平直线。y从0逐渐增大,那么这条水平线就从图片最顶端开始向下扫描。如果y增大到某个值的时候,水平线扫描到该高度上有个点的颜色不是黑色,那么就认为这个y是一行的开始,并把这个y记录下来,同时y增大15。为何要增大15呢?这是因为经过测算发现,每行字符本身的高度大约是11个像素,而行与行直接的高度差大约是19个像素。那么,当我找到一行的最顶端时,向下跳跃15像素的位置,就应该是这一行之后而下一行之前。用这个算法就可以确定每一行的起始高度。确定的每行起始高度如下图:

上面每一条黄线所在的纵坐标就是这一行开始的纵坐标。当然,真实的处理中是不会画黄线的,这里只是为了直观。

既然确定了每一行的起始位置,那么接下来就是识别每一行的内容。以第一行为例,它相当于是这样的一张图片:

那么,我就准备如下的14张图片:

定义一个变量x,x从0逐渐增大。对于每一个x,依次把这14张图片放置到“行图片”的(x,0)的位置上,如果能够重合,那么说明“行图片”中(x,0)的位置是对应的某个字符,同时x加上该字符图片的宽度。

OK,算法是不是很简单?哈哈~~

===================阶段二:代码实现=================

代码分为TradeTick.java、TonghuashunFetcher.java和TonghuashunFetcherTest.java三个文件。

TradeTick.java:

package cn.carrotech.klinefetch.model;

/**
 * 一笔交易明细
 */
public class TradeTick
{

    //时间
    private String time;
    //价格
    private double price;
    //成交量
    private int volume;

    /**
     * 获取时间
     * @return 时间
     */
    public String getTime()
    {
        return time;
    }

    /**
     * 设置时间
     * @param time 时间
     */
    public void setTime(String time)
    {
        this.time=time;
    }

    /**
     * 获取价格
     * @return 价格
     */
    public double getPrice()
    {
        return price;
    }

    /**
     * 设置价格
     * @param price 价格
     */
    public void setPrice(double price)
    {
        this.price=price;
    }

    /**
     * 获取成交量
     * @return 成交量
     */
    public int getVolume()
    {
        return volume;
    }

    /**
     * 设置成交量
     * @param volume 成交量
     */
    public void setVolume(int volume)
    {
        this.volume=volume;
    }

    @Override
    public boolean equals(Object obj)
    {
        if(!(obj instanceof TradeTick))
            return false;
        TradeTick tick=(TradeTick)obj;
        if(time==null)
        {
            if(tick.getTime()!=null)
                return false;
        }
        else
        {
            if(!time.equals(tick.getTime()))
                return false;
        }
        if(getPrice()!=tick.getPrice())
            return false;
        if(getVolume()!=tick.getVolume())
            return false;
        return true;
    }

}

TonghuashunFetcher.java:

package cn.carrotech.klinefetch.fetcher;

import java.awt.AWTException;
import java.awt.Rectangle;
import java.awt.Robot;
import java.awt.event.InputEvent;
import java.awt.event.KeyEvent;
import java.awt.image.BufferedImage;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.imageio.ImageIO;
import cn.carrotech.klinefetch.model.TradeTick;

/**
 * 用于核新同花顺的抓取器
 */
public class TonghuashunFetcher
{

    //每一行文字的高度(要大于文字本身的高度11,并小于与下一行的距离19)
    private static final int TICK_LINE_HEIGHT=15;

    //字符与图片文件对应表
    private static final String[][] DIGIT_FILE_MAP={{"0","0.png"},{"1","1.png"},{"2","2.png"},{"3","3.png"},
            {"4","4.png"},{"5","5.png"},{"6","6.png"},{"7","7.png"},{"8","8.png"},{"9","9.png"},{":","colon.png"},
            {".","dot.png"},
            {" ","space.png"},
            {"0","-.png"}};

    //图片文件根目录
    private static final String DIGIT_FILE_BASE="/resources/TonghuashunDigits/";

    //一开始按page up键的次数
    private static final int PAGE_UP_PRESS_COUNT=30;

    //字符
    private static String sDigits[];
    //字符图片
    private static BufferedImage[] sDigitImages;

    static
    {
        //建立字符与图片映射
        sDigitImages=new BufferedImage[DIGIT_FILE_MAP.length];
        sDigits=new String[DIGIT_FILE_MAP.length];
        try
        {
            for(int i=0;i<DIGIT_FILE_MAP.length;i++)
            {
                sDigits[i]=DIGIT_FILE_MAP[i][0];
                String path=DIGIT_FILE_BASE+DIGIT_FILE_MAP[i][1];
                InputStream input=TonghuashunFetcher.class.getResourceAsStream(path);
                sDigitImages[i]=ImageIO.read(input);
            }
        }
        catch(Exception ex)
        {
            ex.printStackTrace();
        }
    }

    //截图的窗口的x坐标
    private int captureX;
    //截图的窗口的y坐标
    private int captureY;
    //截图的窗口的宽度
    private int captureWidth;
    //截图的窗口的高度
    private int captureHeight;
    //最大行数
    private int lineCount;
    //机器人
    private Robot robot;

    public TonghuashunFetcher() throws AWTException
    {
        robot=new Robot();
    }

    /**
     * 获取截图的x坐标
     *
     * @return x坐标
     */
    public int getCaptureX()
    {
        return captureX;
    }

    /**
     * 设置截图的x坐标
     *
     * @param captureX x坐标
     */
    public void setCaptureX(int captureX)
    {
        this.captureX=captureX;
    }

    /**
     * 获取截图的y坐标
     *
     * @return y坐标
     */
    public int getCaptureY()
    {
        return captureY;
    }

    /**
     * 设置截图的y坐标
     *
     * @param captureY y坐标
     */
    public void setCaptureY(int captureY)
    {
        this.captureY=captureY;
    }

    /**
     * 获取截图的宽度
     *
     * @return 宽度
     */
    public int getCaptureWidth()
    {
        return captureWidth;
    }

    /**
     * 设置截图的宽度
     *
     * @param captureWidth 宽度
     */
    public void setCaptureWidth(int captureWidth)
    {
        this.captureWidth=captureWidth;
    }

    /**
     * 获取截图的高度
     *
     * @return 高度
     */
    public int getCaptureHeight()
    {
        return captureHeight;
    }

    /**
     * 设置截图的高度
     *
     * @param captureHeight 高度
     */
    public void setCaptureHeight(int captureHeight)
    {
        this.captureHeight=captureHeight;
    }

    /**
     * 获取最大行数
     * @return 最大行数
     */
    public int getLineCount()
    {
        return lineCount;
    }

    /**
     * 设置最大行数
     * @param lineCount 最大行数
     */
    public void setLineCount(int lineCount)
    {
        this.lineCount=lineCount;
    }

    /**
     * 获取指定日期的所有交易明细,按时间升序排序
     * @param date 日期
     * @return 所有的交易明细
     */
    public List<TradeTick> fetchTradeTicks(Date date)
    {
        //激活交易明细窗口
        mouseClick(captureX+captureWidth/2,captureY+captureHeight/2,InputEvent.BUTTON1_MASK);
        //滚动交易明细到最上面
        for(int i=0;i<PAGE_UP_PRESS_COUNT;i++)
            keyInput(KeyEvent.VK_PAGE_UP);
        //截图区域
        Rectangle captureArea=new Rectangle(captureX,captureY,captureWidth,captureHeight);
        //要返回的结果
        List<TradeTick> result=new ArrayList<TradeTick>();
        //新增行数小于lineCount的次数
        int lessThanLineCountTimes=0;
        while(true)
        {
            //截图
            BufferedImage image=robot.createScreenCapture(captureArea);
            //获取图片内的所有交易明细
            List<TradeTick> ticks=recognizeTradeTicks(image);
            int resultSize=result.size();
            int ticksSize=ticks.size();
            //重合的行数
            int overlapLineCount=Math.min(resultSize,ticksSize);
            for(;overlapLineCount>0;overlapLineCount--)
                if(isListOverlapAt(result,resultSize-overlapLineCount,ticks))
                    break;
            int newLineCount=(ticksSize-overlapLineCount);
            System.out.println("newLen="+newLineCount);
            if(newLineCount>0)
            {
                //ticks中从overlapLineCount开始的元素都要追加到result之后
                for(int i=overlapLineCount;i<ticksSize;i++)
                    result.add(ticks.get(i));
            }
            else
            {
                TradeTick lastTick=result.get(resultSize-1);
                if("15:00".compareTo(lastTick.getTime())<=0)
                    break;
                if("14:59".compareTo(lastTick.getTime())<=0&&lastTick.getVolume()==0)
                    break;
            }
            if(newLineCount<lineCount)
            {
                lessThanLineCountTimes++;
                if(lessThanLineCountTimes>1)
                    throw new RuntimeException("need restart!");
            }
            keyInput(KeyEvent.VK_PAGE_DOWN);
            sleep(50);
        }
        System.out.println(result.size());
        return result;
    }

    //firstList从index处之后的内容是否为secondList开头的内容
    private <T> boolean isListOverlapAt(List<T> firstList,int index,List<T> secondList)
    {
        //firstList剩余的元素个数
        int restLen=firstList.size()-index;
        //secondList没有这么多元素,肯定不重合
        if(secondList.size()<restLen) return false;
        for(int i=0;i<restLen;i++)
        {
            T first=firstList.get(index+i);
            T second=secondList.get(i);
            if(first==null)
            {
                if(second!=null)
                    return false;
            }
            else
            {
                if(!first.equals(second))
                    return false;
            }
        }
        return true;
    }

    // 给定一张图片,识别其中所有的交易明细
    private List<TradeTick> recognizeTradeTicks(BufferedImage image)
    {
        // 转成黑白图
        image=transformToBlackAndWhite(image);
        // 要返回的结果
        List<TradeTick> ticks=new ArrayList<TradeTick>();
        int width=image.getWidth();
        int height=image.getHeight();
        for(int y=0;y<height;y++)
        {
            for(int x=0;x<width;x++)
            {
                int rgb=image.getRGB(x,y)&0x00FFFFFF;
                // 如果某一个y上面存在一个点不是黑色,那么这个y下面就有一行tick
                if(rgb!=0)
                {
                    TradeTick tick=recognizeATradeTick(image,y);
                    ticks.add(tick);
                    // 直接跳到下一行
                    y+=TICK_LINE_HEIGHT-1;
                    break;
                }
            }
        }
        return ticks;
    }

    // 把彩色图片转成黑白图片
    private BufferedImage transformToBlackAndWhite(BufferedImage image)
    {
        int width=image.getWidth();
        int height=image.getHeight();
        BufferedImage newImage=new BufferedImage(width,height,BufferedImage.TYPE_4BYTE_ABGR);
        for(int y=0;y<height;y++)
        {
            for(int x=0;x<width;x++)
            {
                int rgb=image.getRGB(x,y)&0x00FFFFFF;
                // #C0C0C0是时间的颜色,#FF3232是涨时的价格颜色,#00E600是跌时的价格颜色,#FFFFFF是平时的价格颜色,
                // #02E2F4为成交量的颜色
                if(rgb==0xC0C0C0||rgb==0xFF3232||rgb==0x00E600||rgb==0xFFFFFF||rgb==0x02E2F4)
                    newImage.setRGB(x,y,0xFFFFFF);
                else
                    newImage.setRGB(x,y,0x000000);
            }
        }
        return newImage;
    }

    // 在图片指定的y坐标出识别出一笔交易明细
    private TradeTick recognizeATradeTick(BufferedImage image,int startY)
    {
        // 所有识别出的字符
        StringBuilder sb=new StringBuilder();
        int width=image.getWidth();
        // 从左到右扫描
        for(int x=0;x<width;x++)
        {
            // 依次尝试每一个字符
            for(int i=0;i<sDigitImages.length;i++)
            {
                // 吻合
                if(isImageEqual(image,sDigitImages[i],x,startY))
                {
                    sb.append(sDigits[i]);
                    x+=sDigitImages[i].getWidth()-1;
                    break;
                }
            }
        }
        String raw=sb.toString().trim();
        String[] items=raw.split("\s+");
        if(items.length!=3) throw new RuntimeException("wrong recognization :'"+raw+"'");
        TradeTick tick=new TradeTick();
        tick.setTime(items[0]);
        tick.setPrice(Double.parseDouble(items[1]));
        tick.setVolume(Integer.parseInt(items[2]));
        return tick;
    }

    // 判断在图片的指定位置是否与小图重合
    private boolean isImageEqual(BufferedImage image,BufferedImage subImage,int startX,int startY)
    {
        // 图片大小
        int width=image.getWidth();
        int height=image.getHeight();
        // 子图大小
        int subWidth=subImage.getWidth();
        int subHeight=subImage.getHeight();
        // 遍历子图所有像素
        for(int subY=0;subY<subHeight;subY++)
        {
            for(int subX=0;subX<subWidth;subX++)
            {
                // 坐标映射到主图上
                int x=startX+subX;
                int y=startY+subY;
                // 越界了肯定不重合
                if(x>=width||y>=height) return false;
                // 颜色不同肯定不重合
                int rgb=image.getRGB(x,y)&0x00FFFFFF;
                int subRgb=subImage.getRGB(subX,subY)&0x00FFFFFF;
                if(rgb!=subRgb) return false;
            }
        }
        return true;
    }

    //在指定位置点击鼠标
    private void mouseClick(int x,int y,int button)
    {
        robot.mouseMove(x,y);
        sleep(50);
        robot.mousePress(button);
        sleep(50);
        robot.mouseRelease(button);
        sleep(50);
    }

    // 按指定的键
    private void keyInput(int keyCode)
    {
        robot.keyPress(keyCode);
        sleep(50);
        robot.keyRelease(keyCode);
        sleep(50);
    }

    //延时
    private void sleep(long interval)
    {
        try
        {
            Thread.sleep(interval);
        }
        catch(InterruptedException ex)
        {
            ex.printStackTrace();
        }
    }

}

TonghuashunFetcherTest.java:

import java.io.FileOutputStream;
import java.io.PrintWriter;
import java.util.List;
import cn.carrotech.klinefetch.fetcher.TonghuashunFetcher;
import cn.carrotech.klinefetch.model.TradeTick;

public class TonghuashunFetcherTest
{

    public static void main(String[] args) throws Exception
    {
        //===================参数处理===================
        if(args.length<6)
        {
            System.out.println("usage: <captureX> <captureY> <captureW> <captureH> <lineCount> <outputFile>");
            return;
        }
        int captureX=Integer.parseInt(args[0]);
        int captureY=Integer.parseInt(args[1]);
        int captureW=Integer.parseInt(args[2]);
        int captureH=Integer.parseInt(args[3]);
        int lineCount=Integer.parseInt(args[4]);
        String outputFile=args[5];

        //====================初始化=====================
        PrintWriter writer=new PrintWriter(new FileOutputStream(outputFile));
        TonghuashunFetcher fetcher=new TonghuashunFetcher();
        fetcher.setCaptureX(captureX);
        fetcher.setCaptureY(captureY);
        fetcher.setCaptureWidth(captureW);
        fetcher.setCaptureHeight(captureH);
        fetcher.setLineCount(lineCount);
        List<TradeTick> ticks=fetcher.fetchTradeTicks(null);

        //====================输出======================
        for(TradeTick tick:ticks)
            writer.println(String.format("%st%.2ft%s",tick.getTime(),tick.getPrice(),tick.getVolume()));
        writer.close();
    }

}

当然,还需要把上面的14张图片放置在/TonghuashunDigits/目录下。

整个项目可以点击下载:TonghuashunFetcherTest.rar

代码里还是考虑到了很多实际操作时的问题,比如需要自动按page up键和page down键。同时还需要考虑因为网络延时导致页面滚动滞后等问题。当程序在抓取交易明细的时候,如果程序抛出异常“need restart!”,那么基本上就是采集过程中出现了某些错误。

====================阶段三:采集数据试验===================

把项目发布成jar包(TonghuashunFetcher.jar),假设放在D盘根目录下,那么可以按图中这样的方式来使用:

程序运行完之后,可以看到采集的数据以及保存在文件中: