From 65be28fb0a8ab258aec372cbd6aa65f170900c35 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 16 Apr 2024 16:25:31 -0400 Subject: [PATCH] massive Shape rework: keep both tvPoints/Props and ourPoints/Props; delegate model updates to subclasses; DCA/VLine working but Ladder/HLine not done. --- src/charts/chart.js | 25 +- src/charts/shape.js | 355 +++++++++++++++---------- src/common.js | 3 +- src/components/chart/BuilderPanel.vue | 69 +++-- src/components/chart/ChartOrder.vue | 2 +- src/components/chart/DCABuilder.vue | 163 ++++++++++-- src/components/chart/LimitBuilder.vue | 10 +- src/components/chart/MarketBuilder.vue | 8 +- src/components/chart/RungBuilder.vue | 352 ++++++++++++++++++++++++ src/misc.js | 35 ++- src/orderbuild.js | 60 ++++- 11 files changed, 874 insertions(+), 208 deletions(-) create mode 100644 src/components/chart/RungBuilder.vue diff --git a/src/charts/chart.js b/src/charts/chart.js index 7a43b08..09b8fd8 100644 --- a/src/charts/chart.js +++ b/src/charts/chart.js @@ -1,6 +1,7 @@ import {useChartOrderStore} from "@/orderbuild.js"; import {invokeCallbacks, prototype} from "@/common.js"; import {DataFeed, initFeeDropdown, lookupSymbol} from "@/charts/datafeed.js"; +import {intervalToSeconds} from "@/misc.js"; export let widget = null export let chart = null @@ -24,6 +25,11 @@ function changeSymbol(symbol) { } +function changeInterval(interval, _timeframe) { + useChartOrderStore().intervalSecs = intervalToSeconds(interval) +} + + /* TradingView event keystrings const subscribeEvents = [ 'toggle_sidebar', 'indicators_dialog', 'toggle_header', 'edit_object_dialog', 'chart_load_requested', @@ -69,8 +75,10 @@ function initChart() { console.log('init chart') chart = widget.activeChart() chart.crossHairMoved().subscribe(null, (point)=>setTimeout(()=>handleCrosshairMovement(point),0) ) - chart.onSymbolChanged().subscribe(null, changeSymbol); + chart.onSymbolChanged().subscribe(null, changeSymbol) + chart.onIntervalChanged().subscribe(null, changeInterval) changeSymbol(chart.symbolExt()) + changeInterval(widget.symbolInterval().interval) useChartOrderStore().chartReady = true console.log('chart ready') } @@ -242,7 +250,6 @@ function drawingEventWorker() { const queue = drawingEventQueue drawingEventQueue = [] propsEvents = {} - // console.log('events locked', queue) for (const [id, event] of queue) { if (event === 'properties_changed') propsEvents[id] = event @@ -259,8 +266,18 @@ function drawingEventWorker() { } function doHandleDrawingEvent(id, event) { - // console.log('drawing event', id, event) - const shape = event === 'remove' ? null : chart.getShapeById(id); + console.log('drawing event', id, event) + let shape + if (event==='remove') + shape = null + else { + try { + shape = chart.getShapeById(id) + } + catch { + return + } + } if (event === 'create') { const co = useChartOrderStore(); const callbacks = co.drawingCallbacks diff --git a/src/charts/shape.js b/src/charts/shape.js index 8422b7e..c659989 100644 --- a/src/charts/shape.js +++ b/src/charts/shape.js @@ -3,6 +3,8 @@ import {invokeCallback, mixin} from "@/common.js"; import {chart, createShape, deleteShapeId, dragging, draggingShapeIds, drawShape, widget} from "@/charts/chart.js"; import {unique} from "@/misc.js"; +import {useChartOrderStore} from "@/orderbuild.js"; +import model from "color"; // @@ -37,34 +39,96 @@ export const ShapeType = { } -class Shape { - constructor(type, model={}, onModel=null, onDelete=null, props=null) { +export class Shape { + + constructor(type, onModel=null, onDelete=null, props=null) { // the Shape object manages synchronizing internal data with a corresponding TradingView shape + + // each shape in the class hierarchy overrides setModel() to cause effects in TradingView + // TV callbacks are returned to the on*() handlers, primarily onPoints() and onProps(). Handlers + // of TV callbacks should call updateModel() with just the kv's they want to change. Any changes + // are then passed onto the onModel() callback. + + // the model object has various attributes defined by each shape subclass. each subclass's constructor + // must call setModel(model) after first calling super.constructor() and optionally declaring any model + // defaults on this.model. + + // Shape: { + // lineColor, textColor, color, // lineColor and textColor default to color + // } + + this.debug = false this.id = null // TradingView shapeId, or null if no TV shape created yet (drawing mode) this.type = type // ShapeType - this.model = model // subclass-specific - this.points = null - this.points = this.pointsFromModel() - // console.log('construct points', this.points) - this.props = props === null ? this.propsFromModel() : mixin(props, this.propsFromModel()) + this.model = {} // set to nothing at first + this.ourPoints = null + this.tvPoints = null + this.ourProps = {} + if (props !== null) + this.ourProps = mixin(props, this.ourProps) + this.tvProps = null if (onModel !== null) this.onModel = onModel if (onDelete !== null ) this.onDelete = onDelete - this.create() + + // Model values handled by this base class + this.model.color = null + this.model.lineColor = null + this.model.textColor = null + } // // primary interface methods // + setModel(model) { + if (model.textColor || model.lineColor) { + if (model.textColor) + this.model.textColor = model.textColor + if (model.lineColor) + this.model.lineColor = model.lineColor + this.setProps(this.colorProps()) + } + // todo text + } + + onModel(model, changedKeys) {} // model was changed by a TradingView user action + + updateModel(changes) { + if (this.debug) console.log('updateModel', this.id, changes) + const changedKeys = [] + for (const k of Object.keys(changes)) { + if( changes[k] !== undefined && changes[k] !== this.model[k] ) { + changedKeys.push(k) + this.model[k] = changes[k] + } + } + if (changedKeys.length) + this.onModel(this.model, changedKeys) + } + + colorProps() { + if (!this.model.color&&!this.model.lineColor&&!this.model.textColor) return null + const o = {} + const p = this.type.drawingProp + const lc = this.model.lineColor ? this.model.lineColor : this.model.color; + const tc = this.model.textColor ? this.model.textColor : this.model.color; + if (lc) + o[p+".linecolor"] = lc + if (tc) + o[p+".textcolor"] = tc + return o + } + draw() { // have the user draw this shape - console.log(`draw ${this.type.name}`, this.model) + if (this.debug) console.log(`draw ${this.type.name}`, this.model) if (this.id) throw Error(`Shape already exists ${this.id}`) const or = this.drawingOverrides(); - // console.log('drawing overrides', or) + // if (this.debug) console.log('drawing overrides', or) widget.applyOverrides(or) drawShape(this.type, new ShapeTVCallbacks(this)) } @@ -72,30 +136,28 @@ class Shape { // return an object with property defaults, e.g. { "linetoolhorzline.linecolor": "#7f11e0" } // https://www.tradingview.com/charting-library-docs/latest/api/modules/Charting_Library#drawingoverrides drawingOverrides() { - if (this.model.color===null) return null - const o = {} - const p = this.type.drawingProp - o[p+".linecolor"] = this.model.color - o[p+".textcolor"] = this.model.color - return o + return this.colorProps() } create() { if (this.id !== null) return // programatically create the shape using the current this.points - if( this.points && this.points.length ) { + if( this.ourPoints && this.ourPoints.length ) { this.doCreate() } } doCreate() { - createShape(this.type, this.points, {overrides:this.props}, new ShapeTVCallbacks(this)) + // createShape(this.type, this.points, {overrides:this.props}, new ShapeTVCallbacks(this)) + this.tvPoints = [...this.ourPoints] + this.id = createShape(this.type, this.ourPoints, {overrides:this.ourProps}, new ShapeTVCallbacks(this)) + if (this.debug) console.log('created', this.type.name, this.ourPoints, this.id) } createOrDraw() { if(this.id) return - if(this.points && this.points.length) - this.doCreate(this.points) + if(this.ourPoints && this.ourPoints.length) + this.doCreate(this.ourPoints) else this.draw() } @@ -106,36 +168,25 @@ class Shape { } - setModel(model, props=null) { - for( const [k,v] of Object.entries(model)) - this.model[k] = v - this.setPointsIfDirty(this.pointsFromModel()); - let p - const mp = this.propsFromModel(); - if (props===null) { - p = mp - if (p===null) - return - } - else if (mp !== null) - p = mixin(props, mp) - this.setPropsIfDirty(p) - } - - setPoints(points) { - this.points = points + // setting points to null will delete the shape from the chart. setting points to a valid value will cause the + // shape to be drawn. + if (this.debug) console.log('setPoints', points) + this.ourPoints = points if (points === null || !points.length) this.delete() else { if (this.id === null) this.doCreate() - else { + else if (dirtyPoints(this.tvPoints, points)) { const s = this.tvShape(); - if (!dragging) { + if (this.debug) console.log('adjusting tv points', s, this.tvPoints, points) + if (!this.beingDragged()) { + if (this.debug) console.log('not dragging. use setPoints.') s.setPoints(points) } else { + if (this.debug) console.log('dragging. use QUIET setPoints.') // quiet setPoints doesn't disturb tool editing mode const i = s._pointsConverter.apiPointsToDataSource(points) // s._model.startChangingLinetool(this._source) @@ -147,67 +198,39 @@ class Shape { } } - - setPointsIfDirty(points) { - if (dirtyPoints(this.points, points)) - this.setPoints(points) - } - + onPoints(points) {} // the control points of an existing shape were changed setProps(props) { - if(this.id) - this.tvShape().setProperties(props) + if (!props || Object.keys(props).length===0) return + if (this.debug) console.log('setProps', props) + this.ourProps = mixin(props, this.ourProps) + if(this.id) { + const p = dirtyItems(this.tvProps, props) + this.tvProps = this.tvProps === null ? p : mixin(p, this.tvProps) + this.tvShape().setProperties(p) + } } - - setPropsIfDirty(props) { - // console.log('dirtyProps', this.props, props, dirtyProps(this.props, props)) - if( dirtyProps(this.props, props) ) - this.setProps(props) + onProps(props) { // the display properties of an existing shape were changed + this.updateModel({lineColor:props.linecolor, textColor:props.textcolor}) } - beingDragged() { return draggingShapeIds.indexOf(this.id) !== -1 } delete() { - // console.log('shape.delete', this.id) + if (this.debug) console.log('shape.delete', this.id) + this.ourPoints = null if (this.id === null) return - this.lock++ - try { - deleteShapeId(this.id) - this.id = null - } finally { - this.lock-- - } + deleteShapeId(this.id) + this.id = null } // - // Model synchronization - // - - onDirtyModel(toModel) { - const old = {...this.model} - toModel.call(this) - const dirty = dirtyKeys(old, this.model) - // console.log('onDirtyModel', old, this.model, dirty) - if (dirty.length) - this.onModel(this.model) - } - - pointsFromModel() {return null} - pointsToModel() {} // set the model using this.points - propsFromModel() {return null} - propsToModel() {} // set the model using this.props - - onModel(model) {} // called whenever points or props updates the model dictionary - - - // - // Overridable shape callbacks + // Overridable shape callbacks initiated by TradingView // onDraw() {} // start drawing a new shape for the builder @@ -215,8 +238,6 @@ class Shape { onUndraw() {} // drawing was canceled by clicking on a different tool onAddPoint() {} // the user clicked a point while drawing (that point is added to the points list) onCreate(points, props) {} // the user has finished creating all the control points. drawing mode is exited and the initial shape is created. - onPoints(points) {} // the control points of an existing shape were changed - onProps(props) {} // the display properties of an existing shape were changed onMove(points) {} // the shape was moved by dragging a drawing element not the control point onDrag(points) {} onHide(props) {} @@ -226,7 +247,6 @@ class Shape { } - function dirtyPoints(pointsA, pointsB) { if (pointsB===null) return pointsA !== null @@ -243,21 +263,6 @@ function dirtyPoints(pointsA, pointsB) { return false } - -// B is modifying A -function dirtyProps(propsA, propsB) { - if (propsB===null) - return propsA !== null - const entries = Object.entries(propsB); - if (propsA===null) - return entries.length > 0 - for( const [k,v] of entries) - if ( !(k in propsA) || propsA[k] !== v ) - return true - return false -} - - // B is modifying A function dirtyKeys(propsA, propsB) { if (propsB===null) @@ -265,7 +270,15 @@ function dirtyKeys(propsA, propsB) { if (propsA===null) return [...Object.keys(propsB)] const keys = unique([...Object.keys(propsA), ...Object.keys(propsB)]) - return keys.filter((k)=> !(k in propsA) || propsA[k] !== propsB[k]) + return keys.filter((k)=> !(k in propsA) || propsA[k] !== undefined && propsA[k] !== propsB[k]) +} + + +function dirtyItems(a, b) { + const result = {} + for (const k of dirtyKeys(this.tvProps, props)) + result[k] = props[k] + return result } @@ -278,113 +291,173 @@ class ShapeTVCallbacks { } onCreate(shapeId, _tvShape, points, props) { - if( this.shape.id ) - throw Error(`Created a shape ${shapeId} where one already existed ${this.shape.id}`) - this.shape.id = shapeId - if( this.shape.lock ) return this.creating = true invokeCallback(this.shape, 'onCreate', points, props) } onPoints(shapeId, _tvShape, points) { - this.shape.points = points + if (this.shape.debug) console.log('tvcb onPoints', points) + this.shape.tvPoints = points this.shape.onPoints(points) - this.shape.onDirtyModel(this.shape.pointsToModel) } onProps(shapeId, _tvShape, props) { - // console.log('onProps', props) - if (this.creating) { + // if (this.shape.debug) console.log('tvOnProps', props) + if (this.creating) { // todo still useful? this.creating = false return } - this.shape.props = props + this.shape.tvProps = props this.shape.onProps(props) - this.shape.onDirtyModel(this.shape.propsToModel) } onDraw() { - if( this.shape.lock ) return invokeCallback(this.shape, 'onDraw') } onRedraw() { - if( this.shape.lock ) return invokeCallback(this.shape, 'onRedraw') } onUndraw() { - if( this.shape.lock ) return invokeCallback(this.shape, 'onUndraw') } onAddPoint() { - if( this.shape.lock ) return invokeCallback(this.shape, 'onAddPoint') } onMove(_shapeId, _tvShape, points) { - if( this.shape.lock ) return invokeCallback(this.shape, 'onMove',points) } onDrag(_shapeId, _tvShape, points) { - if( this.shape.lock ) return + if (this.shape.debug) console.log('onDrag') invokeCallback(this.shape, 'onDrag', points) } onHide(_shapeId, _tvShape, props) { - if( this.shape.lock ) return invokeCallback(this.shape, 'onHide',props) } onShow(_shapeId, _tvShape, props) { - if( this.shape.lock ) return invokeCallback(this.shape, 'onShow',props) } onClick(_shapeId, _tvShape) { - if( this.shape.lock ) return invokeCallback(this.shape, 'onClick') } onDelete(shapeId) { this.shape.id = null - if( this.shape.lock ) return invokeCallback(this.shape, 'onDelete', shapeId) } } -export class HLine extends Shape { +export class Line extends Shape { + onDrag(points) { + const s = this.tvShape(); + if (this.debug) { + console.log('shape', s) + console.log('currentMovingPoint', s._source.currentMovingPoint()) + console.log('startMovingPoint', s._source.startMovingPoint()) + console.log('isBeingEdited', s._source.isBeingEdited()) + console.log('state', s._source.state()) + } + } +} + + +export class HLine extends Line { constructor(model, onModel=null, onDelete=null, props=null) { super(ShapeType.HLine, model, onModel, onDelete, props) } - pointsFromModel() { - if (this.model.price === null) return null - const time = this.points !== null && this.points.length > 0 ? this.points[0].time : 0 - return [{time:time, price:this.model.price}] + setModel(model) { + if (model.price !== undefined && model.price !== this.model.price) { + this.model.price = model.price + this.setPoints([{time:0,price:this.model.price}]) + } } - pointsToModel() { - this.model.price = this.points[0].price + onPoints(points) { + super.onPoints(points); } - propsFromModel() { - return this.model.color ? {linecolor: this.model.color} : null + pointsFromModel(model) { + if (model.price === null || model.price===undefined) return null + // take any time available, or 0 + const time = + this.ourPoints !== null && this.ourPoints.length > 0 ? this.ourPoints[0].time : + this.tvPoints !== null && this.tvPoints.length > 0 ? this.tvPoints[0].time : 0 + return [{time:time, price:model.price}] } - propsToModel() {this.model.color=this.props.linecolor} - - onDrag(points) { - const s = this.tvShape(); - console.log('shape', s) - console.log('currentMovingPoint', s._source.currentMovingPoint()) - console.log('startMovingPoint', s._source.startMovingPoint()) - console.log('isBeingEdited', s._source.isBeingEdited()) - console.log('state', s._source.state()) + pointsToModel(points) { + return {price: this.ourPoints[0].price} } } + +function timeAdjustmentTooSmall(orig, newValue) { + // TradingView adjusts our lines to be at the start of the intervals, so + // we ignore deltas smaller than one interval prior + return newValue === undefined || + orig !== null && orig !== undefined && newValue !== null && + newValue < orig && orig - newValue < useChartOrderStore().intervalSecs +} + + +function ohlcStart(time) { + const period = useChartOrderStore().intervalSecs + return Math.floor(time/period) * period +} + + +export class VLine extends Line { + constructor(model, onModel=null, onDelete=null, props=null) { + super(ShapeType.VLine, onModel, onDelete, props) + + // Model + this.model.time = null + + this.setModel(model) // call setModel at the end + } + + onPoints(points) { + if (this.debug) console.log('vline onPoints', this.ourPoints, points) + super.onPoints(points); + const orig = this.ourPoints && this.ourPoints.length ? this.ourPoints[0].time : null + if (!timeAdjustmentTooSmall(orig, points[0].time)) { + if (this.debug) console.log('updateModel', points[0].time) + this.updateModel({time: points[0].time}) + } + } + + setModel(model) { + if (this.debug) console.log('vline setModel', this.model.time, model ) + super.setModel(model) + if (model.time !== undefined && model.time !== this.model.time) { + this.model.time = model.time + const time = ohlcStart(model.time); + if (this.debug) console.log('vline setPoints', this.id, time) + this.setPoints([{time, price:1}]) + } + } + + delete() { + this.model.time = null + super.delete() + } + + dirtyPoints(pointsA, pointsB) { + const a = pointsA ? pointsA[0].time : null + const b = pointsB ? pointsB[0].time : null + const result = !timeAdjustmentTooSmall(a, b) + if (this.debug) console.log('vline dirty points?', a, b, result) + return result + } +} + diff --git a/src/common.js b/src/common.js index 0f5a73d..be40122 100644 --- a/src/common.js +++ b/src/common.js @@ -3,7 +3,7 @@ export function mixin(child, ...parents) { // assigned by parents order, highest priority first for( const parent of parents ) { for ( const key in parent) { - if (parent.hasOwnProperty(key) && !child.hasOwnProperty(key)) { + if (parent.hasOwnProperty(key) && !child.hasOwnProperty(key) && parent[key] !== undefined) { child[key] = parent[key]; } } @@ -11,7 +11,6 @@ export function mixin(child, ...parents) { return child; } - export function prototype(parent, child) { const result = Object.create(parent); Object.assign(result, child) diff --git a/src/components/chart/BuilderPanel.vue b/src/components/chart/BuilderPanel.vue index 2be65bf..40dc444 100644 --- a/src/components/chart/BuilderPanel.vue +++ b/src/components/chart/BuilderPanel.vue @@ -1,28 +1,65 @@ \ No newline at end of file + diff --git a/src/components/chart/ChartOrder.vue b/src/components/chart/ChartOrder.vue index d3ac233..01936bd 100644 --- a/src/components/chart/ChartOrder.vue +++ b/src/components/chart/ChartOrder.vue @@ -30,7 +30,7 @@
Add condition: - + DCA Limit