diff --git a/api/quote.go b/api/quote.go index 05a5a2b..470df5f 100644 --- a/api/quote.go +++ b/api/quote.go @@ -2,6 +2,7 @@ package api import ( "asng/models" + "asng/models/value" "asng/proto" "asng/utils" "fmt" @@ -19,13 +20,16 @@ func (a *App) CandleStick(id models.StockIdentity, period proto.CandleStickPerio type TodayQuoteResp struct { models.BaseResp - Price []models.QuoteFrameDataSingleValue - AvgPrice []models.QuoteFrameDataSingleValue - Volume []models.QuoteFrameDataSingleValue + Frames []models.QuoteFrameRealtime } func (a *App) TodayQuote(id models.StockIdentity) TodayQuoteResp { resp := TodayQuoteResp{} + resp.BaseResp = models.BaseResp{ + Code: 0, + Message: "success", + } + var err error tx, err := a.cli.TXToday(id) if err != nil { @@ -34,14 +38,17 @@ func (a *App) TodayQuote(id models.StockIdentity) TodayQuoteResp { resp.Message = "fail" return resp } - resp.Price = make([]models.QuoteFrameDataSingleValue, 0) - resp.AvgPrice = make([]models.QuoteFrameDataSingleValue, 0) - resp.Volume = make([]models.QuoteFrameDataSingleValue, 0) + + resp.Frames = make([]models.QuoteFrameRealtime, 0, len(tx)) t0 := utils.GetTodayWithOffset(0, 0, 0) txOffsetInOneMinute := 0 tickCur := uint16(0) + var ( + totalVolume int64 = 0 + totalSum int64 = 0 + ) for _, item := range tx { if item.Tick == tickCur { txOffsetInOneMinute += 3 // 3 seconds per tick @@ -56,19 +63,15 @@ func (a *App) TodayQuote(id models.StockIdentity) TodayQuoteResp { 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, - Scale: 1, + + totalVolume += item.Volume + totalSum += item.Price * item.Volume + + resp.Frames = append(resp.Frames, models.QuoteFrameRealtime{ + QuoteFrame: f, + Price: value.IntWithScale(item.Price, 10000), + AvgPrice: value.IntWithScale(totalSum/totalVolume, 10000), + Volume: value.IntWithScale(item.Volume, 1), }) } return resp diff --git a/api/subscribe.go b/api/subscribe.go index 5cb239c..3192268 100644 --- a/api/subscribe.go +++ b/api/subscribe.go @@ -2,6 +2,7 @@ package api import ( "asng/models" + "asng/models/value" "asng/proto" "time" @@ -34,8 +35,7 @@ func NewQuoteSubscripition(app *App) *QuoteSubscripition { type QuoteSubscribeResp struct { RealtimeInfo proto.RealtimeInfoRespItem - PriceFrame models.QuoteFrameDataSingleValue - VolumeFrame models.QuoteFrameDataSingleValue + Frame models.QuoteFrameRealtime } func (a *QuoteSubscripition) Start() { @@ -60,19 +60,13 @@ func (a *App) Subscribe(req []models.StockIdentity) []QuoteSubscribeResp { func (a *App) generateQuoteSubscribeResp(d proto.RealtimeInfoRespItem) QuoteSubscribeResp { return QuoteSubscribeResp{ RealtimeInfo: d, - PriceFrame: models.QuoteFrameDataSingleValue{ + Frame: models.QuoteFrameRealtime{ QuoteFrame: models.QuoteFrame{ TimeInMs: d.TickMilliSecTimestamp, }, - Value: d.CurrentPrice, - Scale: int64(a.stockMetaMap[d.ID].Scale), - }, - VolumeFrame: models.QuoteFrameDataSingleValue{ - QuoteFrame: models.QuoteFrame{ - TimeInMs: d.TickMilliSecTimestamp, - }, - Value: d.CurrentVolume, - Scale: 1, + Price: value.IntWithScale(d.CurrentPrice, int64(a.stockMetaMap[d.ID].Scale)), + AvgPrice: value.IntWithScale(int64(d.TotalAmount)/int64(d.TotalVolume), int64(a.stockMetaMap[d.ID].Scale)), + Volume: value.Int(d.CurrentVolume), }, } } diff --git a/frontend/src/components/RealtimeGraph.tsx b/frontend/src/components/RealtimeGraph.tsx index 58e8976..e2cf7ee 100644 --- a/frontend/src/components/RealtimeGraph.tsx +++ b/frontend/src/components/RealtimeGraph.tsx @@ -1,12 +1,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { api, models, proto } from "../../wailsjs/go/models"; -import { TodayQuote } from "../../wailsjs/go/api/App"; +import { models, proto } from "../../wailsjs/go/models"; import * as d3 from "d3"; -import { LogInfo } from "../../wailsjs/runtime/runtime"; import { formatPrice } from "./Viewer"; type RealtimeGraphProps = { - priceLine: models.QuoteFrameDataSingleValue[]; + quote: models.QuoteFrameRealtime[]; + meta: models.StockMetaItem; + realtime: proto.RealtimeInfoRespItem; }; function RealtimeGraph(props: RealtimeGraphProps) { @@ -29,25 +29,34 @@ function RealtimeGraph(props: RealtimeGraphProps) { }; }, [containerRef.current]); - const ml = 40; - const mr = 20; + const ml = 45; + const mr = 45; const mt = 20; const mb = 20; const svgRef = useRef(null); const xAxisRef = useRef(null); const yAxisRef = useRef(null); + const keyYAxisRef = useRef(null); const lineGroupRef = useRef(null); const [priceRange, setPriceRange] = useState([0, 0]); - useEffect(() => { - 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))); + let max = 0; + let min = 1e10; + props.quote.forEach((p) => { + max = Math.max(max, p.Price.V / p.Price.Scale); + min = Math.min(min, p.Price.V / p.Price.Scale); + }); + const delta = Math.max( + Math.abs(props.realtime.YesterdayClose / props.meta.Scale - min), + Math.abs(max - props.realtime.YesterdayClose / props.meta.Scale) + ); - setPriceRange([Math.min(...set), Math.max(...set)]); - }, [props.priceLine]); + setPriceRange([ + props.realtime.YesterdayClose / props.meta.Scale - delta, + props.realtime.YesterdayClose / props.meta.Scale + delta, + ]); + }, [props.meta, props.quote]); const [scale, setScale] = useState({ X: d3.scaleTime(), @@ -128,29 +137,64 @@ function RealtimeGraph(props: RealtimeGraphProps) { .attr("stroke-opacity", 0.5) .attr("stroke-dasharray", "2,2") ) - .call((g) => g.selectAll(".tick text").attr("x", -32).attr("dy", 2)) + .call((g) => g.selectAll(".tick text").attr("x", -ml)) + .call((g) => g.select(".domain").remove()); + + d3.select(keyYAxisRef.current!) + .call( + d3 + .axisRight(scale.Y) + .tickValues( + [props.realtime.YesterdayClose / props.meta.Scale].concat( + priceRange + ) + ) + .tickSize(dimensions.width - ml - mr) + .tickFormat(d3.format(".2f")) + ) + // .call((g) => g.selectAll(".tick text").attr("x", -32).attr("dy", 2)) .call((g) => g.select(".domain").remove()); }, [dimensions, scale]); - const lineBuilder = useCallback( + const priceLineBuilder = useCallback( d3 - .line() + .line() .x((d) => scale.X(new Date(d.TimeInMs))) - .y((d) => scale.Y(d.Value / d.Scale)), + .y((d) => scale.Y(d.Price.V / d.Price.Scale)), + [scale] + ); + const avgPriceLineBuilder = useCallback( + d3 + .line() + .x((d) => scale.X(new Date(d.TimeInMs))) + .y((d) => scale.Y(d.AvgPrice.V / d.AvgPrice.Scale)), [scale] ); - // draw price line const pricePathRef = useRef(null); useEffect(() => { - if (!lineBuilder || props.priceLine.length == 0) { + if (!priceLineBuilder || props.quote.length == 0) { return; } - d3.select(pricePathRef.current).attr("d", lineBuilder(props.priceLine)); + d3.select(pricePathRef.current).attr("d", priceLineBuilder(props.quote)); return () => { d3.select(pricePathRef.current).attr("d", ""); }; - }, [lineBuilder, props.priceLine, pricePathRef.current]); + }, [priceLineBuilder, props.quote, pricePathRef.current]); + // draw avg price line + const avgPricePathRef = useRef(null); + useEffect(() => { + if (!avgPriceLineBuilder || props.quote.length == 0) { + return; + } + d3.select(avgPricePathRef.current).attr( + "d", + avgPriceLineBuilder(props.quote) + ); + return () => { + d3.select(avgPricePathRef.current).attr("d", ""); + }; + }, [avgPriceLineBuilder, props.quote, avgPricePathRef.current]); // draw crosshair const crosshairRef = useRef(null); @@ -208,31 +252,38 @@ function RealtimeGraph(props: RealtimeGraphProps) { focused, ]); - const [frame, setFrame] = useState(); + const [frame, setFrame] = useState(); // set current frame useEffect(() => { if (!focused) { - setFrame(props.priceLine[0] ? props.priceLine[0] : undefined); + if (props.quote.length > 0) { + setFrame(props.quote[props.quote.length - 1]); + } return; } const curXInMilliSec = scale.X.invert(curPos[0]).getTime(); let minDelta = curXInMilliSec; let minDeltaIndex = 0; - props.priceLine.forEach((p, i) => { + props.quote.forEach((p, i) => { if (Math.abs(p.TimeInMs - curXInMilliSec) < minDelta) { minDelta = Math.abs(p.TimeInMs - curXInMilliSec); minDeltaIndex = i; } }); - setFrame(props.priceLine[minDeltaIndex]); - }, [curPos, scale, focused, props.priceLine]); + setFrame(props.quote[minDeltaIndex]); + }, [curPos, scale, focused, props.quote]); return (
-
分时图 {props.priceLine.length}
-
{frame ? new Date(frame.TimeInMs).toLocaleTimeString() : ""}
-
{frame ? (frame.Value / frame.Scale).toFixed(2) : ""}
+
分时图 {props.quote.length}
+
{frame ? new Date(frame.TimeInMs).toLocaleTimeString() : "-"}
+
+ {frame ? formatPrice(frame.Price.V / frame.Price.Scale) : "-"} +
+
+ {frame ? formatPrice(frame.AvgPrice.V / frame.AvgPrice.Scale) : "-"} +
+ + diff --git a/frontend/src/components/Viewer.tsx b/frontend/src/components/Viewer.tsx index a5955fe..422fe20 100644 --- a/frontend/src/components/Viewer.tsx +++ b/frontend/src/components/Viewer.tsx @@ -15,19 +15,11 @@ type ViewerProps = { function Viewer(props: ViewerProps) { const [data, setData] = useState(); - const [dataList, setDataList] = useState([]); - const setDataWrapper = useCallback( - (d: proto.RealtimeInfoRespItem) => { - setData(d); - setDataList(dataList.concat(d)); - }, - [dataList] - ); const [meta, setMeta] = useState(); - const [transaction, updateTransaction] = useReducer( + const [quoteList, updateQuoteList] = useReducer( ( - state: models.QuoteFrameDataSingleValue[], - data: { action: string; data: models.QuoteFrameDataSingleValue[] } + state: models.QuoteFrameRealtime[], + data: { action: string; data: models.QuoteFrameRealtime[] } ) => { switch (data.action) { case "init": @@ -42,26 +34,9 @@ function Viewer(props: ViewerProps) { }, [] ); - + const [yesterdayData, setYesterdayData] = + useState(); const [refreshAt, setRefreshAt] = useState(new Date()); - const [priceLine, updatePriceLine] = useReducer( - ( - state: models.QuoteFrameDataSingleValue[], - data: { action: string; data: models.QuoteFrameDataSingleValue[] } - ) => { - switch (data.action) { - case "init": - return data.data.concat(state); - case "append": - return state.concat(data.data); - case "reset": - return []; - default: - return state; - } - }, - [] - ); // 0. get meta useEffect(() => { @@ -72,11 +47,7 @@ function Viewer(props: ViewerProps) { }); }, [props.id]); useEffect(() => { - updatePriceLine({ - action: "reset", - data: [], - }); - updateTransaction({ + updateQuoteList({ action: "reset", data: [], }); @@ -93,20 +64,19 @@ function Viewer(props: ViewerProps) { if (d.RealtimeInfo.Code !== meta!.ID.Code) { return; } - setDataWrapper(d.RealtimeInfo); - updatePriceLine({ - action: "append", - data: [d.PriceFrame], - }); - updateTransaction({ + updateQuoteList({ action: "append", - data: [d.VolumeFrame], + data: [d.Frame], }); setRefreshAt(new Date()); + const ri = d.RealtimeInfo; + console.log( + `${ri.CurrentPrice} ${ri.RUint0} ${ri.RUint1} ${ri.RUint2} ${ri.RIntArray2}` + ); } ); return cancel; - }, [meta, setDataWrapper]); + }, [meta]); // 2. fetch current data and subscribe useEffect(() => { @@ -117,7 +87,15 @@ function Viewer(props: ViewerProps) { Subscribe([meta.ID]).then((d) => { if (d[0]?.RealtimeInfo.Code === meta.ID.Code) { setData(d[0].RealtimeInfo); + updateQuoteList({ + action: "append", + data: [d[0].Frame], + }); setRefreshAt(new Date()); + const ri = d[0].RealtimeInfo; + console.log( + `${ri.CurrentPrice} ${ri.RUint0} ${ri.RUint1} ${ri.RUint2} ${ri.RIntArray2}` + ); } }); }, 15 * 1000); @@ -127,15 +105,9 @@ function Viewer(props: ViewerProps) { console.error(d); return; } - console.log(d.Price); - updatePriceLine({ - action: "init", - data: d.Price, - }); - console.log(d.Volume); - updateTransaction({ + updateQuoteList({ action: "init", - data: d.Volume, + data: d.Frames, }); }); return () => { @@ -168,12 +140,16 @@ function Viewer(props: ViewerProps) { />
- + {meta && data ? ( + + ) : ( +
Loading...
+ )}
- +
{data && meta ? (
@@ -197,61 +173,34 @@ function Viewer(props: ViewerProps) { export default Viewer; type TxViewerProps = { - priceLine: models.QuoteFrameDataSingleValue[]; - volumeLine: models.QuoteFrameDataSingleValue[]; + quote: models.QuoteFrameRealtime[]; }; function TxViewer(props: TxViewerProps) { - const renderCount = 50; - const [tx, setTx] = useState< - { - price: models.QuoteFrameDataSingleValue; - volume: models.QuoteFrameDataSingleValue; - }[] - >(); - - useEffect(() => { - if (props.priceLine.length == props.volumeLine.length) { - setTx( - props.priceLine.map((d, i) => { - return { - price: d, - volume: props.volumeLine[i], - }; - }) - ); - } - }, [props.priceLine, props.volumeLine]); - - if (!props.priceLine || !props.volumeLine) { + if (!props.quote) { return
Loading...
; } - if (props.priceLine.length != props.volumeLine.length) { - console.error("Price line and volume line length not equal"); - console.log(props.priceLine.length, props.volumeLine.length); - return
Price line and volume line length not equal
; - } return ( { return ( -
+
- {new Date(d.price.TimeInMs).toLocaleTimeString()} + {new Date(d.TimeInMs).toLocaleTimeString()}
- {formatPrice(d.price.Value, d.price.Scale)} + {formatPrice(d.Price.V, d.Price.Scale)}
-
{d.volume.Value}
+
{d.Volume.V}
{formatAmount( - (d.volume.Value /* 手 */ * 100 * d.price.Value) / d.price.Scale + (d.Volume.V /* 手 */ * 100 * d.Price.V) / d.Price.Scale )} 元
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 7eafec5..b566d02 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -8,8 +8,7 @@ export namespace api { } export class QuoteSubscribeResp { RealtimeInfo: proto.RealtimeInfoRespItem; - PriceFrame: models.QuoteFrameDataSingleValue; - VolumeFrame: models.QuoteFrameDataSingleValue; + Frame: models.QuoteFrameRealtime; static createFrom(source: any = {}) { return new QuoteSubscribeResp(source); @@ -18,8 +17,7 @@ export namespace api { constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); this.RealtimeInfo = this.convertValues(source["RealtimeInfo"], proto.RealtimeInfoRespItem); - this.PriceFrame = this.convertValues(source["PriceFrame"], models.QuoteFrameDataSingleValue); - this.VolumeFrame = this.convertValues(source["VolumeFrame"], models.QuoteFrameDataSingleValue); + this.Frame = this.convertValues(source["Frame"], models.QuoteFrameRealtime); } convertValues(a: any, classs: any, asMap: boolean = false): any { @@ -78,9 +76,7 @@ export namespace api { export class TodayQuoteResp { Code: number; Message: string; - Price: models.QuoteFrameDataSingleValue[]; - AvgPrice: models.QuoteFrameDataSingleValue[]; - Volume: models.QuoteFrameDataSingleValue[]; + Frames: models.QuoteFrameRealtime[]; static createFrom(source: any = {}) { return new TodayQuoteResp(source); @@ -90,9 +86,7 @@ export namespace api { 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); + this.Frames = this.convertValues(source["Frames"], models.QuoteFrameRealtime); } convertValues(a: any, classs: any, asMap: boolean = false): any { @@ -229,15 +223,16 @@ export namespace models { this.Msg = source["Msg"]; } } - export class QuoteFrameDataSingleValue { + export class QuoteFrameRealtime { Type: number; TimeInMs: number; TimeSpanInMs: number; - Value: number; - Scale: number; + Price: value.V; + AvgPrice: value.V; + Volume: value.V; static createFrom(source: any = {}) { - return new QuoteFrameDataSingleValue(source); + return new QuoteFrameRealtime(source); } constructor(source: any = {}) { @@ -245,9 +240,28 @@ export namespace models { this.Type = source["Type"]; this.TimeInMs = source["TimeInMs"]; this.TimeSpanInMs = source["TimeSpanInMs"]; - this.Value = source["Value"]; - this.Scale = source["Scale"]; + this.Price = this.convertValues(source["Price"], value.V); + this.AvgPrice = this.convertValues(source["AvgPrice"], value.V); + this.Volume = this.convertValues(source["Volume"], value.V); } + + 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; + } } export class ServerStatus { Connected: boolean; @@ -506,3 +520,24 @@ export namespace proto { } +export namespace value { + + export class V { + T: number; + V: number; + Scale: number; + + static createFrom(source: any = {}) { + return new V(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.T = source["T"]; + this.V = source["V"]; + this.Scale = source["Scale"]; + } + } + +} + diff --git a/models/quote.go b/models/quote.go index cbd4e52..521e21d 100644 --- a/models/quote.go +++ b/models/quote.go @@ -1,6 +1,9 @@ package models -import "fmt" +import ( + "asng/models/value" + "fmt" +) type StockIdentity struct { MarketType MarketType @@ -70,6 +73,13 @@ type QuoteFrameDataSingleValue struct { Scale int64 } +type QuoteFrameRealtime struct { + QuoteFrame + Price value.V + AvgPrice value.V + Volume value.V +} + type QuoteFrameDataCandleStick struct { QuoteFrame Open int64 diff --git a/models/value/value.go b/models/value/value.go new file mode 100644 index 0000000..d775a10 --- /dev/null +++ b/models/value/value.go @@ -0,0 +1,30 @@ +package value + +type T uint16 + +const ( + ValueTypeUnknown T = 0 + ValueTypeFloatWithScale T = 1 +) + +func Int(v int64) V { + return V{ + T: ValueTypeFloatWithScale, + V: v, + Scale: 1, + } +} + +func IntWithScale(v int64, scale int64) V { + return V{ + T: ValueTypeFloatWithScale, + V: v, + Scale: scale, + } +} + +type V struct { + T T + V int64 + Scale int64 +} diff --git a/proto/0547_realtime_info.go b/proto/0547_realtime_info.go index 00daefa..da9767a 100644 --- a/proto/0547_realtime_info.go +++ b/proto/0547_realtime_info.go @@ -118,7 +118,7 @@ type RealtimeInfoRespItem struct { RUint1 uint32 RUint2 uint32 RB string - RIntArray2 [4*5 + 4]int64 + RIntArray2 [4*5 + 4]int64 // l2 的十档数据 } func (obj *RealtimeInfoRespItem) Unmarshal(ctx context.Context, buf []byte, cursor *int) error {