All Articles

F# 初接触 - 获取实时股票数据

作为一根韭菜,很重要的当然是盯盘,这时候你是要在上班的时候掏出手机看还是打开网页看?

作为一根合格的韭菜,答案当然是用命令行了!够低调,同时内容又高度定制化。

而作为一根在 .NET 的生态里讨饭吃的韭菜,我首选的工具是 F#。

为什么选择 FSharp

  • F# 是 Functional base 的编程语言,相较于C#,语法更灵活也更简洁。同时 F# 并不如 Haskell 那般 strict,可以使用多范式去构建程序,所以也可以很方便的使用 .NET 上其他的绝大多数 DLL 库,无缝和 C# 进行交互。换言之,.NET 拥有的能力,F# 都可以有。

  • 相较于 C#,F# 拥有更强大的类型系统。

  • 虽然相对于其他 OOP 风格的语言来说,C# 已经有了 LINQ 这样的大杀器,但是 F# 借助 computation expression 的扩展,不但支持类 LINQ 的语法 Query Expression,而且更强大,也更易于扩展。

  • 同样得益于 computation expression,F# 有更好的异步支持 Async Workflow

简单讲就是 C# 有的 F# 都有,同时 F# 可以更简洁。

初始化项目

进入正题,首先,需要去创建一个新的 Project。

dotnet new console -lang F# -o TStock

这里的 -lang 指定使用的编程语言是 F#, -o 指定了项目目录名称。

然后在生成的 Program.fs 中可以看到如下代码

// Learn more about F# at http://fsharp.org

open System

[<EntryPoint>]
let main argv =
    printfn "Hello World from F#!"
    0 // return an integer exit code

使用 dotnet run 执行可以得到输出 Hello World from F#!

使用 paket 管理依赖

paket 是 F# 社区实现的一款包管理工具,相较于 nuget 来说更灵活一些,可以同时用来管理 nugetgithub 上的依赖。

choco install paket

如果没有安装过 paket,可以用 chocolatey 方便的安装 paket, 安装好之后,需要在项目中初始化 paket

paket init

这是时候项目中多出了 .paket, .paket-files, paket.dependencies

paket.dependencies 中写入依赖如下

source https://www.nuget.org/api/v2

nuget FSharp.Core
nuget FSharp.Data
nuget FSharp.Json

使用 .\paket\paket.exe install 安装依赖。

这时候声明的依赖都安装到了 packages 目录下,那么怎么使用呢?

在和 *.fsproj同目录的地方创建一个 paket.references 文件,里面写入这个项目的依赖

FSharp.Core
FSharp.Data
FSharp.Json

这主要是因为考虑到通常一个 solution 下面需要很多个 project 的情况。

再跑一次 .\paket\paket.exe install, .paket 目录下多了一个 Paket.Restore.targets 文件,fsproj 文件中也多出了一行

    <Import Project=".paket\Paket.Restore.targets" />

通过这个文件,就可以在项目中引用到安装的依赖了。

编写代码

先保留 main 函数里面的部分不动,开始代码逻辑部分的处理。

type Code = Code of string

type StockCode =
    | SH of Code
    | SZ of Code
    | HK of Code
    | NSDQ of Code

type Stock = {
    Name: string
    Code: StockCode
}

Code 是股票编码,但是 A 股、港股、美股等都有不同情况要处理,如何进行区分?所以我引入了 StockCode 的 union type,这样就有办法描述更多的股票了。

如果用 C# 会是什么情况?

class BaseCode
{
    private string _code { get; set; }
    public string Code
    {
        get() {
            return _code;
        }
    }

    Sz(string code)
    {
        _code = code
    }

    public string ToString()
    {
        return Code;
    }
}

class SHCode: BaseCode {
    SHCode(code: string): base(code) {}
    public override string ToString()
    {
        return "sh" + Code;
    }
}

class SZCode: BaseCode
{
    /// blabla
}

class HKCode: BaseCode
{
    /// blabla
}

/// blabla

接下来就是数据部分了,这部分不是我的重点,很多网站都有股票实时数据,比如新浪股票,都是可以的选择。值得注意的是,这些网站都加了反爬虫的技术,另一方面,股票数据要实时刷新,为了逻辑上统一,股票数据一般都是由异步接口提供,jsonp 或者 json,具体的接口不同的网站不同,需要自己去分析。

找到接口之后,就可以愉快的拿数据来玩耍了。

type StockData = {
    Price: float
    Open: float
    High: float
    Low: float
    UpDown: float
    UpDownRate: float
    ExchangeRatio: float
}

type StockResult = {
    Stock: Stock
    Data: StockData option
}

这里 StockData 定义了拿到的数据格式,StockResult 定义了完整的返回结果,将数据信息和股票信息关联起来,方便后面的处理。

let getStockData (stock: Stock) =
    async {
        let code = stock.Code
        let baseURL = sprintf "http://%s.%s/%s/quotelist" (getHostPrefix code) baseHost (getRegion code)
        let url = sprintf "%s?code=%s&column=%s&callback=%s" baseURL (getCodeString code) column callback
        let! resp = Http.AsyncRequest url
        if resp.StatusCode > 300 then
            return { Stock = stock; Data = None }
        else
            let stockData = resp.Body.ToString() |> parseData code
            return { Stock = stock; Data = stockData }
    }

这里的逻辑很简单,就是拿到传入一只股票的基本信息,根据股票的 code 信息拼出完整 url,再通过一个异步请求去获取数据。这里的异步请求本身可能因为各种原因失败,所以这里的 Data 是一个 Option 类型的结果。

我这里取的是一个 jsonp 的请求,不能直接 deserialize 成一个对象,所以用了一个额外的 parseData 方法来处理数据

let parseData code (text: string) =
    try
        let valuePart = text.Split(":").[1]
        let unit = match code with
                    | HK _ -> 1000.0
                    | _ -> 100.0
        valuePart.Substring(3, valuePart.Length - 10).Split(",")
        |> Seq.map (float >> (fun x -> x / unit))
        |> Seq.toArray
        |> fun s ->
            Some {
                Price = s.[0]
                Open = s.[1]
                High = s.[2]
                Low = s.[3]
                UpDown = s.[4]
                UpDownRate = s.[5]
                ExchangeRatio = s.[6]
            }
    with
        | _ -> None

这里 parse 本身是一个极容易发生错误的过程,返回结果不对,或者返回结果不规范都有可能导致失败,由于我不关心错误原因,所以只要错误返回 None 就可以了,try 里面只关心正常的逻辑,这样可以让代码更好读一点。

这里面有两个奇怪的符号 |>>>,它们本质上都是函数,|> 的定义是 ( |> ): 'T1 -> ('T1 -> 'U) -> 'U,即将左侧的参数 apply 到右侧的函数中,返回其结果,类似于一个管道,将数据流处理通过一个个函数串联起来,基本上是 fsharp 中最常见的符号了。>> 的定义是 ( >> ) : ('T1 -> 'T2) -> ('T2 -> 'T3) -> 'T1 -> 'T3,即 compose 函数,将两个函数组装成一个函数。

还有一个比较 tricky 的事实是,fsharp 去处理这样的中缀符号的结合性的时候,是通过符号的形状来决定的,而不是像 Haskell 那样去显式的声明其结合性和优先级。

有了这些方法之后,主要功能就基本上完成了。最后来更改 main 函数。

[<EntryPoint>]
let main argv =
    let stocks = [
        {
            Name = "MSFT";
            Code = NSDQ (Code "MSFT")
        }
    ]

    stocks
    |> Seq.map getStockData
    |> Async.Parallel
    |> Async.RunSynchronously
    |> Seq.iter
        (fun result ->
            match result with
            | result when result.Data = None -> printfn "|%-20s|%10s|%10s|%10s|%10s|%10s|%10s|" result.Stock.Name "_" "_" "_" "_" "_" "_"
            | { StockResult.Stock = stock; StockResult.Data = Some data } ->
                printfn "|%-20s|%10.2f|%10.2f|%10.2f|%10.2f|%10.2f|%10.2f%%|" stock.Name data.Price data.Open data.Low data.High data.UpDown data.UpDownRate
            | _ -> ()
        )
    0

这里有使用 printfn 将最后拿到的结果打印成表格呈现到终端。

扩展应用

为了让代码更灵活,这里把 stocks 放到一个外部 json 文件中,通过命令行参数进行捕获。这里我用到的是 FSharp.Json 这个库。

首先需要给最初定义的 StockCodeStock 类型加一些辅助的 attribute。这里涉及到一个 union type 的映射问题,当然,这里的代码是非常直白的。

open FSharp.Json

[<JsonUnion(Mode=UnionMode.CaseKeyAsFieldValue, CaseKeyField="type", CaseValueField="code")>]
type StockCode =
    | SH of Code
    | SZ of Code
    | HK of Code
    | NSDQ of Code

type Stock = {
    [<JsonField("name")>]
    Name: string
    [<JsonField("code")>]
    Code: StockCode
}

然后是更改我们的 main 函数。通过 argv 传入 filename,再通过 File 把内容读取到程序中,最后通过 FSharp.Json deserialize 成我们需要的结构,其他统统保持不变。

[<EntryPoint>]
let main argv =
    let filename = argv.[0]
    let content = File.ReadAllText(filename)
    let stocks = Json.deserialize<Stock[]>(content)

    // ...

下面是 stocks.json 文件的一个示例。

[
    {
        "name": "MSFT",
        "code": {
            "type": "NSDQ",
            "code": "MSFT"
        }
    }
]

以上,就是整个应用的主要实现了,当然为了节省篇幅,中间省去了一些细枝末节。

完整的代码可以去访问我的 Github 查看。