From 89b00ba1b25fb13f8278fbbfa3deda1c0f1592f3 Mon Sep 17 00:00:00 2001 From: eric Date: Wed, 4 Dec 2024 13:31:31 +0800 Subject: [PATCH] feat: realtime graph use transaction data now --- api/quote.go | 57 +++++++- examples/main.go | 6 +- frontend/src/components/RealtimeGraph.tsx | 166 ++++++++-------------- frontend/src/components/Viewer.tsx | 42 +++++- frontend/wailsjs/go/api/App.d.ts | 2 +- frontend/wailsjs/go/models.ts | 74 +++++++--- models/models.go | 7 + models/quote.go | 44 ++++++ utils/util.go | 7 + 9 files changed, 270 insertions(+), 135 deletions(-) diff --git a/api/quote.go b/api/quote.go index 7b2c9d9..c8a5d31 100644 --- a/api/quote.go +++ b/api/quote.go @@ -3,7 +3,9 @@ package api import ( "asng/models" "asng/proto" + "asng/utils" "fmt" + "time" ) func (a *App) CandleStick(id models.StockIdentity, period proto.CandleStickPeriodType, cursor uint16) *proto.CandleStickResp { @@ -15,13 +17,60 @@ func (a *App) CandleStick(id models.StockIdentity, period proto.CandleStickPerio return resp } -func (a *App) TodayQuote(id models.StockIdentity) []proto.QuoteFrame { - resp, err := a.cli.RealtimeGraph(id) +type TodayQuoteResp struct { + models.BaseResp + Price []models.QuoteFrameDataSingleValue + AvgPrice []models.QuoteFrameDataSingleValue + Volume []models.QuoteFrameDataSingleValue +} + +func (a *App) TodayQuote(id models.StockIdentity) TodayQuoteResp { + resp := TodayQuoteResp{} + var err error + tx, err := a.cli.TXToday(id) if err != nil { a.LogProcessError(models.ProcessInfo{Msg: fmt.Sprintf("today quote failed: %s", err.Error())}) - return nil + resp.Code = 1 + resp.Message = "fail" + return resp } - return resp.List + resp.Price = make([]models.QuoteFrameDataSingleValue, 0) + resp.AvgPrice = make([]models.QuoteFrameDataSingleValue, 0) + resp.Volume = make([]models.QuoteFrameDataSingleValue, 0) + + t0 := utils.GetTodayWithOffset(0, 0, 0) + txOffsetInOneMinute := 0 + tickCur := uint16(0) + + for _, item := range tx { + if item.Tick == tickCur { + txOffsetInOneMinute += 3 // 3 seconds per tick + } else { + tickCur = item.Tick + txOffsetInOneMinute = 0 + } + f := models.QuoteFrame{ + TimeInMs: t0.Add( + time.Duration(item.Tick)*time.Minute + + time.Duration(txOffsetInOneMinute)*time.Second). + UnixMilli(), + TimeSpanInMs: time.Second.Milliseconds(), + } + resp.Price = append(resp.Price, models.QuoteFrameDataSingleValue{ + QuoteFrame: f.Clone().SetType(models.QuoteTypeLine), + Value: item.Price, + Scale: 10000, + }) + // resp.AvgPrice = append(resp.AvgPrice, models.QuoteFrameDataSingleValue{ + // QuoteFrame: f.Clone().SetType(models.QuoteTypeLine), + // Value: item.AvgPrice, + // }) + // resp.Volume = append(resp.Volume, models.QuoteFrameDataSingleValue{ + // QuoteFrame: f.Clone().SetType(models.QuoteTypeBar), + // Value: item.Volume, + // }) + } + return resp } func (a *App) RealtimeInfo(req []models.StockIdentity) *proto.RealtimeInfoResp { diff --git a/examples/main.go b/examples/main.go index 3c5d9bf..8910872 100644 --- a/examples/main.go +++ b/examples/main.go @@ -135,9 +135,9 @@ func testSubscribe() { // resp, err := cli.TXToday(models.StockIdentity{ // MarketType: models.MarketSH, Code: "600000", // }) - // resp, err := cli.TXRealtime(models.StockIdentity{ - // MarketType: models.MarketSH, Code: "600000"}) - resp, err := cli.CandleStick(models.StockIdentity{MarketType: models.MarketSH, Code: "600000"}, proto.CandleStickPeriodType_Day, 0) + resp, err := cli.TXRealtime(models.StockIdentity{ + MarketType: models.MarketSH, Code: "600000"}, 0) + // resp, err := cli.CandleStick(models.StockIdentity{MarketType: models.MarketSH, Code: "600000"}, proto.CandleStickPeriodType_Day, 0) if err != nil { fmt.Printf("error:%s", err) return diff --git a/frontend/src/components/RealtimeGraph.tsx b/frontend/src/components/RealtimeGraph.tsx index 793a999..755ae2d 100644 --- a/frontend/src/components/RealtimeGraph.tsx +++ b/frontend/src/components/RealtimeGraph.tsx @@ -1,22 +1,20 @@ import { useEffect, useRef, useState } from "react"; -import { models, proto } from "../../wailsjs/go/models"; +import { api, models, proto } from "../../wailsjs/go/models"; import { TodayQuote } from "../../wailsjs/go/api/App"; import * as d3 from "d3"; import { LogInfo } from "../../wailsjs/runtime/runtime"; import { formatPrice } from "./Viewer"; type RealtimeGraphProps = { - id: models.StockIdentity; - realtimeData: proto.RealtimeInfoRespItem | undefined; + priceLine: models.QuoteFrameDataSingleValue[]; }; + function RealtimeGraphProps(props: RealtimeGraphProps) { const containerRef = useRef(null); const [dimensions, setDimensions] = useState({ width: 0, height: 0, }); - const [data, setData] = useState([]); - const [cursorX, setCursorX] = useState(0); useEffect(() => { const resizeObserver = new ResizeObserver((entries) => { @@ -31,16 +29,6 @@ function RealtimeGraphProps(props: RealtimeGraphProps) { }; }, [containerRef.current]); - useEffect(() => { - if (!props.id.Code) { - return; - } - TodayQuote(props.id).then((data) => { - setData(data); - setCursorX(data.length - 1); - }); - }, [props.id]); - const ml = 40; const mr = 20; const mt = 20; @@ -49,62 +37,69 @@ function RealtimeGraphProps(props: RealtimeGraphProps) { const xAxisRef = useRef(null); const yAxisRef = useRef(null); const lineGroupRef = useRef(null); + const [priceRange, setPriceRange] = useState([0, 0]); + useEffect(() => { - if (!data || !props.realtimeData) { - return; - } - const yesterdayClose = - (props.realtimeData.CurrentPrice + - props.realtimeData.YesterdayCloseDelta) / - 100.0; + let set = []; + + // max and min price in price line + set.push(Math.max(...props.priceLine.map((p) => p.Value / p.Scale))); + set.push(Math.min(...props.priceLine.map((p) => p.Value / p.Scale))); + + console.log(set); + + setPriceRange([Math.min(...set), Math.max(...set)]); + }, [props.priceLine]); + + useEffect(() => { + const widthPer5Min = (dimensions.width - ml - mr) / 50; const xScale = d3 - .scaleLinear() - .domain([0, 240]) - .range([ml, dimensions.width - mr]); - const yScale = d3 - .scaleLinear() + .scaleTime() .domain([ - Math.min( - ...data.map((d) => { - return yesterdayClose - Math.abs(d.Price / 100.0 - yesterdayClose); - }) - ) - - 0.01 * yesterdayClose, - Math.max( - ...data.map((d) => { - return yesterdayClose + Math.abs(d.Price / 100.0 - yesterdayClose); - }) - ) + - 0.01 * yesterdayClose, + new Date().setHours(9, 15, 0, 0), + new Date().setHours(9, 25, 0, 0), + new Date().setHours(9, 30, 0, 0), + new Date().setHours(11, 30, 0, 0), + new Date().setHours(13, 0, 0, 0), + new Date().setHours(15, 0, 0, 0), ]) + .range([ + ml, + ml + widthPer5Min * 2, + ml + widthPer5Min * 2, + ml + widthPer5Min * (2 + 24), + ml + widthPer5Min * (2 + 24), + dimensions.width - mr, + ]); + const yScale = d3 + .scaleLinear() + .domain(priceRange) .range([dimensions.height - mb, mt]); + d3.select(xAxisRef.current!) .call( d3 .axisTop(xScale) .tickSize(dimensions.height - mt - mb) - .tickValues( - [0 /* FIXME d3 bug? */].concat([ - 0, 30, 60, 90, 120, 150, 180, 210, 240, - ]) - ) + .tickValues([ + new Date().setHours(9, 15, 0, 0), // TODO: d3 don't show first value, is this d3 bug? + new Date().setHours(9, 15, 0, 0), + new Date().setHours(9, 30, 0, 0), + new Date().setHours(10, 0, 0, 0), + new Date().setHours(10, 30, 0, 0), + new Date().setHours(11, 0, 0, 0), + new Date().setHours(11, 30, 0, 0), + new Date().setHours(13, 30, 0, 0), + new Date().setHours(14, 0, 0, 0), + new Date().setHours(14, 30, 0, 0), + new Date().setHours(15, 0, 0, 0), + ]) .tickFormat((d) => { - if (d.valueOf() < 120) { - return ( - 9 + - Math.floor((d.valueOf() + 30) / 60) + - ":" + - d3.format("02d")((d.valueOf() + 30) % 60) - ); - } else if (d.valueOf() == 120) { + const t0 = new Date(d.valueOf()); + if (t0.getHours() == 11 && t0.getMinutes() == 30) { return "11:30/13:00"; } else { - return ( - 13 + - Math.floor((d.valueOf() - 120) / 60) + - ":" + - d3.format("02d")((d.valueOf() - 120) % 60) - ); + return d3.timeFormat("%H:%M")(t0); } }) ) @@ -119,17 +114,6 @@ function RealtimeGraphProps(props: RealtimeGraphProps) { .axisRight(yScale) .tickSize(dimensions.width - ml - mr) .tickFormat(d3.format(".2f")) - .tickValues( - yScale - .domain() - .concat( - Array.from({ length: 7 }, (_, i) => i).map( - (i) => - yesterdayClose + - (i - 3) * ((yScale.domain()[1] - yesterdayClose) / 4) - ) - ) - ) ) .call((g) => g @@ -140,53 +124,27 @@ function RealtimeGraphProps(props: RealtimeGraphProps) { .call((g) => g.selectAll(".tick text").attr("x", -32).attr("dy", 2)) .call((g) => g.select(".domain").remove()); - const lineZero = d3 - .line() - .x((d, i) => xScale(i)) - .y((d) => yScale(d)); - d3.select(lineGroupRef.current!) - .append("path") - .attr( - "d", - lineZero(Array.from({ length: 240 }, (_, i) => yesterdayClose)) - ) - .attr("fill", "none") - .attr("stroke", "white"); + const linePrice = d3 + .line() + .x((d) => xScale(new Date(d.TimeInMs))) + .y((d) => yScale(d.Value / d.Scale)); - const lineRealtime = d3 - .line() - .x((d, i) => xScale(i)) - .y((d) => yScale(d.Price / 100)); - d3.select(lineGroupRef.current!) - .append("path") - .attr("d", lineRealtime(data!)) - .attr("fill", "none") - .attr("stroke", "white"); - const lineAvg = d3 - .line() - .x((d, i) => xScale(i)) - .y((d) => yScale(d.AvgPrice / 10000)); d3.select(lineGroupRef.current!) .append("path") - .attr("d", lineAvg(data!)) .attr("fill", "none") - .attr("stroke", "yellow"); + .attr("stroke", "white") + .attr("stroke-width", 0.5) + .attr("d", linePrice(props.priceLine)); + return () => { d3.select(lineGroupRef.current!).selectAll("*").remove(); }; - }, [data, props.realtimeData]); + }, [props.priceLine, priceRange]); return (
分时图
-
{props.id.Code}
-
- 现价 {formatPrice(data[cursorX]?.Price, 100)} -
-
- 均价 {formatPrice(data[cursorX]?.AvgPrice, 10000)} -
(); const [dataList, setDataList] = useState([]); - const [meta, setMeta] = useState(); - const [transaction, setTransaction] = useState([]); - const [refreshAt, setRefreshAt] = useState(new Date()); - const setDataWrapper = useCallback( (d: proto.RealtimeInfoRespItem) => { setData(d); @@ -23,7 +20,25 @@ function Viewer(props: ViewerProps) { }, [dataList] ); + const [meta, setMeta] = useState(); + const [transaction, setTransaction] = useState([]); + const [refreshAt, setRefreshAt] = useState(new Date()); + const [priceLine, updatePriceLine] = useReducer( + ( + state: models.QuoteFrameDataSingleValue[], + data: { append: boolean; data: models.QuoteFrameDataSingleValue[] } + ) => { + if (data.append) { + // TODO merge + return state.concat(data.data); + } else { + return data.data.concat(state); + } + }, + [] + ); + // 0. get meta useEffect(() => { StockMeta([props.id]).then((d) => { if (d.length > 0) { @@ -32,6 +47,7 @@ function Viewer(props: ViewerProps) { }); }, [props.id]); + // 1. listen on realtime broadcast useEffect(() => { if (!meta) { return; @@ -49,6 +65,7 @@ function Viewer(props: ViewerProps) { return cancel; }, [meta, setDataWrapper]); + // 2. fetch current data and subscribe useEffect(() => { if (!meta) { return; @@ -61,6 +78,17 @@ function Viewer(props: ViewerProps) { } }); }, 15 * 1000); + + TodayQuote(meta.ID).then((d) => { + if (d.Code) { + console.log(d); + return; + } + updatePriceLine({ + append: false, + data: d.Price, + }); + }); return () => { cancel(); }; @@ -142,7 +170,7 @@ function Viewer(props: ViewerProps) { />
- +
diff --git a/frontend/wailsjs/go/api/App.d.ts b/frontend/wailsjs/go/api/App.d.ts index b80d4c7..6f997da 100755 --- a/frontend/wailsjs/go/api/App.d.ts +++ b/frontend/wailsjs/go/api/App.d.ts @@ -28,4 +28,4 @@ export function StockMeta(arg1:Array):Promise):Promise; -export function TodayQuote(arg1:models.StockIdentity):Promise>; +export function TodayQuote(arg1:models.StockIdentity):Promise; diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 5bc5f70..75d985b 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -38,6 +38,44 @@ export namespace api { return a; } } + export class TodayQuoteResp { + Code: number; + Message: string; + Price: models.QuoteFrameDataSingleValue[]; + AvgPrice: models.QuoteFrameDataSingleValue[]; + Volume: models.QuoteFrameDataSingleValue[]; + + static createFrom(source: any = {}) { + return new TodayQuoteResp(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.Code = source["Code"]; + this.Message = source["Message"]; + this.Price = this.convertValues(source["Price"], models.QuoteFrameDataSingleValue); + this.AvgPrice = this.convertValues(source["AvgPrice"], models.QuoteFrameDataSingleValue); + this.Volume = this.convertValues(source["Volume"], models.QuoteFrameDataSingleValue); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } } @@ -108,6 +146,26 @@ export namespace models { this.Msg = source["Msg"]; } } + export class QuoteFrameDataSingleValue { + Type: number; + TimeInMs: number; + TimeSpanInMs: number; + Value: number; + Scale: number; + + static createFrom(source: any = {}) { + return new QuoteFrameDataSingleValue(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.Type = source["Type"]; + this.TimeInMs = source["TimeInMs"]; + this.TimeSpanInMs = source["TimeSpanInMs"]; + this.Value = source["Value"]; + this.Scale = source["Scale"]; + } + } export class ServerStatus { Connected: boolean; ServerInfo: string; @@ -256,22 +314,6 @@ export namespace proto { this.Volume = source["Volume"]; } } - export class QuoteFrame { - Price: number; - AvgPrice: number; - Volume: number; - - static createFrom(source: any = {}) { - return new QuoteFrame(source); - } - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source); - this.Price = source["Price"]; - this.AvgPrice = source["AvgPrice"]; - this.Volume = source["Volume"]; - } - } export class RealtimeInfoRespItem { ID: models.StockIdentity; Market: number; diff --git a/models/models.go b/models/models.go index be16025..709cd42 100644 --- a/models/models.go +++ b/models/models.go @@ -83,3 +83,10 @@ type BaseDBFRow struct { ID StockIdentity Data map[string]any } + +type BaseReq struct{} + +type BaseResp struct { + Code int + Message string +} diff --git a/models/quote.go b/models/quote.go index c811bbc..cbd4e52 100644 --- a/models/quote.go +++ b/models/quote.go @@ -40,3 +40,47 @@ func (s StockIdentity) FixedSizeWithUint8MarketType() StockIdentityFixedSizeWith Code: s.CodeArray(), } } + +type QuoteType uint16 + +const ( + QuoteTypeUnknown QuoteType = 0 + QuoteTypeLine QuoteType = 1 + QuoteTypeCandleStick QuoteType = 2 + QuoteTypeBar QuoteType = 3 +) + +type QuoteFrame struct { + Type QuoteType + TimeInMs int64 // align to span start + TimeSpanInMs int64 +} + +func (f QuoteFrame) Clone() QuoteFrame { + return f +} +func (f QuoteFrame) SetType(t QuoteType) QuoteFrame { + f.Type = t + return f +} + +type QuoteFrameDataSingleValue struct { + QuoteFrame + Value int64 + Scale int64 +} + +type QuoteFrameDataCandleStick struct { + QuoteFrame + Open int64 + High int64 + Low int64 + Close int64 +} + +type QuoteFrameDataTx struct { + QuoteFrame + Price int64 + Volume int64 + Amount int64 +} diff --git a/utils/util.go b/utils/util.go index d4b585b..14dc594 100644 --- a/utils/util.go +++ b/utils/util.go @@ -1 +1,8 @@ package utils + +import "time" + +func GetTodayWithOffset(h, M, s int) time.Time { + y, m, d := time.Now().Date() + return time.Date(y, m, d, h, M, s, 0, time.Local) +}