mgv-backoffice 1.6.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -2
- package/dist/src/components/EarningsCard.vue.d.ts +2 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/utils/format.d.ts +40 -0
- package/dist/src/utils/pnl.d.ts +24 -0
- package/dist/ui-lib.css +1 -1
- package/dist/ui-lib.js +69 -8
- package/dist/ui-lib.umd.cjs +1 -1
- package/package.json +6 -2
- package/src/components/EarningsCard.vue +10 -8
- package/src/index.ts +11 -0
- package/src/utils/format.test.ts +129 -0
- package/src/utils/format.ts +89 -0
- package/src/utils/httpColors.test.ts +62 -0
- package/src/utils/pnl.test.ts +45 -0
- package/src/utils/pnl.ts +40 -0
- package/src/utils/sanitizeHtml.test.ts +77 -0
- package/src/utils/util.test.ts +28 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale-aware display formatters shared across back-office tables, logs and
|
|
3
|
+
* charts. All are pure and dependency-free; they take the "missing value"
|
|
4
|
+
* branch seriously so callers can hand them raw API values (which may be
|
|
5
|
+
* null/strings) without pre-sanitising.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const EM_DASH = '—'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Format a number with a fixed number of fraction digits, rendering an
|
|
12
|
+
* em-dash for null/undefined/non-finite input. Accepts numeric strings so
|
|
13
|
+
* BigDecimal-as-string API payloads pass through unchanged.
|
|
14
|
+
*/
|
|
15
|
+
export function fmtNumber(n: number | string | null | undefined, digits = 4): string {
|
|
16
|
+
if (n === null || n === undefined) return EM_DASH
|
|
17
|
+
if (typeof n === 'string' && n.trim() === '') return EM_DASH
|
|
18
|
+
const v = typeof n === 'string' ? Number(n) : n
|
|
19
|
+
if (!isFinite(v)) return EM_DASH
|
|
20
|
+
return v.toLocaleString(undefined, {
|
|
21
|
+
minimumFractionDigits: digits,
|
|
22
|
+
maximumFractionDigits: digits,
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Format an ISO date string or epoch value as a locale date-time, falling
|
|
28
|
+
* back to the raw value on parse failure and an em-dash on empty input.
|
|
29
|
+
* Accepts numbers so epoch-millis timestamps pass through unchanged.
|
|
30
|
+
*/
|
|
31
|
+
export function fmtDate(s: string | number | null | undefined): string {
|
|
32
|
+
if (s === null || s === undefined || s === '') return EM_DASH
|
|
33
|
+
const d = new Date(s)
|
|
34
|
+
if (isNaN(d.getTime())) return String(s)
|
|
35
|
+
return d.toLocaleString()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Format an epoch-millis timestamp as a compact "Mon D, HH:MM" label —
|
|
40
|
+
* the form used for chart axes and tooltips.
|
|
41
|
+
*/
|
|
42
|
+
export function fmtDateTime(ms: number): string {
|
|
43
|
+
return new Date(ms).toLocaleString(undefined, {
|
|
44
|
+
month: 'short',
|
|
45
|
+
day: 'numeric',
|
|
46
|
+
hour: '2-digit',
|
|
47
|
+
minute: '2-digit',
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Format an epoch-millis timestamp as a short "Mon D" calendar label.
|
|
53
|
+
*/
|
|
54
|
+
export function fmtDateShort(ms: number): string {
|
|
55
|
+
return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Format a price with precision that scales to magnitude: penny stocks and
|
|
60
|
+
* sub-cent crypto get more decimals so the value never collapses to "0.00".
|
|
61
|
+
*/
|
|
62
|
+
export function fmtPrice(n: number): string {
|
|
63
|
+
if (!isFinite(n) || n === 0) return n.toFixed(2)
|
|
64
|
+
const abs = Math.abs(n)
|
|
65
|
+
let digits = 2
|
|
66
|
+
if (abs < 0.0001) digits = 8
|
|
67
|
+
else if (abs < 0.01) digits = 6
|
|
68
|
+
else if (abs < 1) digits = 4
|
|
69
|
+
else if (abs < 100) digits = 3
|
|
70
|
+
return n.toLocaleString(undefined, {
|
|
71
|
+
minimumFractionDigits: digits,
|
|
72
|
+
maximumFractionDigits: digits,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Format a percentage with an explicit sign (e.g. "+2.50%", "-1.00%").
|
|
78
|
+
*/
|
|
79
|
+
export function fmtPct(n: number, digits = 2): string {
|
|
80
|
+
return `${n >= 0 ? '+' : ''}${n.toFixed(digits)}%`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Format a signed USD amount with an explicit leading sign (e.g. "+$5.00").
|
|
85
|
+
*/
|
|
86
|
+
export function fmtUsd(v: number): string {
|
|
87
|
+
const sign = v < 0 ? '-' : '+'
|
|
88
|
+
return `${sign}$${Math.abs(v).toFixed(2)}`
|
|
89
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
methodBadgeSolid,
|
|
4
|
+
methodBadgeBright,
|
|
5
|
+
statusBadgeSolid,
|
|
6
|
+
statusBadgeTinted,
|
|
7
|
+
} from './httpColors'
|
|
8
|
+
|
|
9
|
+
describe('methodBadgeSolid', () => {
|
|
10
|
+
it('maps known methods (case-insensitive)', () => {
|
|
11
|
+
expect(methodBadgeSolid('GET')).toBe('bg-blue-600')
|
|
12
|
+
expect(methodBadgeSolid('get')).toBe('bg-blue-600')
|
|
13
|
+
expect(methodBadgeSolid('DELETE')).toBe('bg-red-600')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('falls back to gray for missing/unknown methods', () => {
|
|
17
|
+
expect(methodBadgeSolid()).toBe('bg-gray-600')
|
|
18
|
+
expect(methodBadgeSolid('TRACE')).toBe('bg-gray-600')
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('methodBadgeBright', () => {
|
|
23
|
+
it('maps known methods', () => {
|
|
24
|
+
expect(methodBadgeBright('POST')).toBe('bg-green-500')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('falls back to gray', () => {
|
|
28
|
+
expect(methodBadgeBright()).toBe('bg-gray-500')
|
|
29
|
+
expect(methodBadgeBright('WAT')).toBe('bg-gray-500')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('statusBadgeSolid', () => {
|
|
34
|
+
it('buckets by status class', () => {
|
|
35
|
+
expect(statusBadgeSolid(204)).toBe('bg-emerald-600')
|
|
36
|
+
expect(statusBadgeSolid(301)).toBe('bg-blue-600')
|
|
37
|
+
expect(statusBadgeSolid(404)).toBe('bg-amber-600')
|
|
38
|
+
expect(statusBadgeSolid(500)).toBe('bg-red-600')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('falls back to gray for missing/unknown codes', () => {
|
|
42
|
+
expect(statusBadgeSolid()).toBe('bg-gray-600')
|
|
43
|
+
expect(statusBadgeSolid(100)).toBe('bg-gray-600')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('statusBadgeTinted', () => {
|
|
48
|
+
it('returns dark-theme classes', () => {
|
|
49
|
+
expect(statusBadgeTinted(200, true)).toBe('bg-green-500/15 text-green-400')
|
|
50
|
+
expect(statusBadgeTinted(404, true)).toBe('bg-yellow-500/15 text-yellow-400')
|
|
51
|
+
expect(statusBadgeTinted(500, true)).toBe('bg-red-500/15 text-red-400')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns light-theme classes', () => {
|
|
55
|
+
expect(statusBadgeTinted(200, false)).toBe('bg-green-100 text-green-800')
|
|
56
|
+
expect(statusBadgeTinted(404, false)).toBe('bg-yellow-100 text-yellow-800')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('defaults a missing status to 2xx', () => {
|
|
60
|
+
expect(statusBadgeTinted(undefined, false)).toBe('bg-green-100 text-green-800')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { computePnL } from './pnl'
|
|
3
|
+
|
|
4
|
+
describe('computePnL', () => {
|
|
5
|
+
it('computes absolute and percent PnL for a profitable position', () => {
|
|
6
|
+
expect(computePnL({ buyPrice: 100, lastPrice: 110, filledQty: 5 })).toEqual({
|
|
7
|
+
pnlUsd: 50,
|
|
8
|
+
pnlPct: 10,
|
|
9
|
+
})
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('computes a loss', () => {
|
|
13
|
+
expect(computePnL({ buyPrice: 100, lastPrice: 90, filledQty: 2 })).toEqual({
|
|
14
|
+
pnlUsd: -20,
|
|
15
|
+
pnlPct: -10,
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('accepts numeric strings', () => {
|
|
20
|
+
expect(computePnL({ buyPrice: '100', lastPrice: '110', filledQty: '5' })).toEqual({
|
|
21
|
+
pnlUsd: 50,
|
|
22
|
+
pnlPct: 10,
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns nulls when any input is missing', () => {
|
|
27
|
+
const nulls = { pnlUsd: null, pnlPct: null }
|
|
28
|
+
expect(computePnL({ buyPrice: null, lastPrice: 110, filledQty: 5 })).toEqual(nulls)
|
|
29
|
+
expect(computePnL({ buyPrice: 100, lastPrice: undefined, filledQty: 5 })).toEqual(nulls)
|
|
30
|
+
expect(computePnL({ buyPrice: 100, lastPrice: 110, filledQty: null })).toEqual(nulls)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('returns nulls for non-finite inputs (guards Number(null) === 0 footgun)', () => {
|
|
34
|
+
expect(computePnL({ buyPrice: 100, lastPrice: 'abc', filledQty: 5 })).toEqual({
|
|
35
|
+
pnlUsd: null,
|
|
36
|
+
pnlPct: null,
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('returns nulls when buyPrice is not positive', () => {
|
|
41
|
+
const nulls = { pnlUsd: null, pnlPct: null }
|
|
42
|
+
expect(computePnL({ buyPrice: 0, lastPrice: 110, filledQty: 5 })).toEqual(nulls)
|
|
43
|
+
expect(computePnL({ buyPrice: -5, lastPrice: 110, filledQty: 5 })).toEqual(nulls)
|
|
44
|
+
})
|
|
45
|
+
})
|
package/src/utils/pnl.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unrealised profit-and-loss for an open position, expressed in both
|
|
3
|
+
* absolute currency and percentage terms.
|
|
4
|
+
*/
|
|
5
|
+
export interface PnL {
|
|
6
|
+
pnlUsd: number | null
|
|
7
|
+
pnlPct: number | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Minimal position shape needed to compute mark-to-market PnL. */
|
|
11
|
+
export interface PnLInputs {
|
|
12
|
+
buyPrice: number | string | null | undefined
|
|
13
|
+
lastPrice: number | string | null | undefined
|
|
14
|
+
filledQty: number | string | null | undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compute unrealised PnL from the latest observed market price. Returns
|
|
19
|
+
* `{ null, null }` when any input is missing or non-finite (a freshly opened
|
|
20
|
+
* position not yet priced, a corrupt row, etc.) so the UI can render a
|
|
21
|
+
* placeholder instead of a bogus figure.
|
|
22
|
+
*
|
|
23
|
+
* The explicit null guard matters: `Number(null) === 0`, which would
|
|
24
|
+
* otherwise silently produce PnL = -buyPrice * qty when a price is absent.
|
|
25
|
+
*/
|
|
26
|
+
export function computePnL(row: PnLInputs): PnL {
|
|
27
|
+
if (row.buyPrice == null || row.lastPrice == null || row.filledQty == null) {
|
|
28
|
+
return { pnlUsd: null, pnlPct: null }
|
|
29
|
+
}
|
|
30
|
+
const buy = Number(row.buyPrice)
|
|
31
|
+
const last = Number(row.lastPrice)
|
|
32
|
+
const qty = Number(row.filledQty)
|
|
33
|
+
if (!isFinite(buy) || !isFinite(last) || !isFinite(qty) || buy <= 0) {
|
|
34
|
+
return { pnlUsd: null, pnlPct: null }
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
pnlUsd: (last - buy) * qty,
|
|
38
|
+
pnlPct: ((last - buy) / buy) * 100,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { sanitizeHtml, isSafeHref } from './sanitizeHtml'
|
|
3
|
+
|
|
4
|
+
describe('sanitizeHtml', () => {
|
|
5
|
+
it('returns empty string for falsy input', () => {
|
|
6
|
+
expect(sanitizeHtml(null)).toBe('')
|
|
7
|
+
expect(sanitizeHtml(undefined)).toBe('')
|
|
8
|
+
expect(sanitizeHtml('')).toBe('')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('keeps allow-listed formatting tags', () => {
|
|
12
|
+
expect(sanitizeHtml('<b>bold</b>')).toBe('<b>bold</b>')
|
|
13
|
+
expect(sanitizeHtml('<p>para</p>')).toBe('<p>para</p>')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('drops script tags and their contents', () => {
|
|
17
|
+
expect(sanitizeHtml('<p>Hi<script>alert(1)</script></p>')).toBe('<p>Hi</p>')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('drops style/iframe contents entirely', () => {
|
|
21
|
+
expect(sanitizeHtml('<div><style>.x{}</style>text</div>')).toBe('<div>text</div>')
|
|
22
|
+
expect(sanitizeHtml('<iframe src="x"></iframe>after')).toBe('after')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('unwraps disallowed elements to their text', () => {
|
|
26
|
+
expect(sanitizeHtml('<h1>Title</h1>')).toBe('Title')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('strips inline event handlers and other attributes', () => {
|
|
30
|
+
expect(sanitizeHtml('<p onclick="evil()">hi</p>')).toBe('<p>hi</p>')
|
|
31
|
+
expect(sanitizeHtml('<span class="x" style="color:red">y</span>')).toBe('<span>y</span>')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('hardens safe anchors with rel/target', () => {
|
|
35
|
+
const out = sanitizeHtml('<a href="https://example.com">link</a>')
|
|
36
|
+
expect(out).toContain('href="https://example.com"')
|
|
37
|
+
expect(out).toContain('rel="noopener noreferrer"')
|
|
38
|
+
expect(out).toContain('target="_blank"')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('strips unsafe href schemes', () => {
|
|
42
|
+
const out = sanitizeHtml('<a href="javascript:alert(1)">x</a>')
|
|
43
|
+
expect(out).not.toContain('javascript')
|
|
44
|
+
expect(out).not.toContain('href')
|
|
45
|
+
// No surviving href means no link-hardening attributes are added.
|
|
46
|
+
expect(out).not.toContain('target')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('preserves the title attribute on anchors', () => {
|
|
50
|
+
const out = sanitizeHtml('<a href="https://x.io" title="hi">x</a>')
|
|
51
|
+
expect(out).toContain('title="hi"')
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('isSafeHref', () => {
|
|
56
|
+
it('accepts safe schemes and relative/anchor links', () => {
|
|
57
|
+
expect(isSafeHref('http://x.com')).toBe(true)
|
|
58
|
+
expect(isSafeHref('https://x.com')).toBe(true)
|
|
59
|
+
expect(isSafeHref('mailto:a@b.com')).toBe(true)
|
|
60
|
+
expect(isSafeHref('tel:+123')).toBe(true)
|
|
61
|
+
expect(isSafeHref('/relative/path')).toBe(true)
|
|
62
|
+
expect(isSafeHref('#anchor')).toBe(true)
|
|
63
|
+
expect(isSafeHref('')).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('rejects dangerous schemes', () => {
|
|
67
|
+
expect(isSafeHref('javascript:alert(1)')).toBe(false)
|
|
68
|
+
expect(isSafeHref('data:text/html,<script>')).toBe(false)
|
|
69
|
+
expect(isSafeHref('vbscript:msgbox')).toBe(false)
|
|
70
|
+
expect(isSafeHref('file:///etc/passwd')).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('is case- and whitespace-insensitive', () => {
|
|
74
|
+
expect(isSafeHref(' JAVASCRIPT:alert(1) ')).toBe(false)
|
|
75
|
+
expect(isSafeHref(' HTTPS://x.com ')).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { getBaseColor, getBaseColorOf } from './util'
|
|
3
|
+
import { AlertEnum } from '../enums/AlertEnum'
|
|
4
|
+
import { ColorsEnums } from '../enums/ColorsEnums'
|
|
5
|
+
|
|
6
|
+
describe('getBaseColor', () => {
|
|
7
|
+
it('maps each alert variant to a Tailwind colour name', () => {
|
|
8
|
+
expect(getBaseColor(AlertEnum.ERROR)).toBe('red')
|
|
9
|
+
expect(getBaseColor(AlertEnum.SUCCESS)).toBe('green')
|
|
10
|
+
expect(getBaseColor(AlertEnum.INFO)).toBe('gray')
|
|
11
|
+
expect(getBaseColor(AlertEnum.WARNING)).toBe('yellow')
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('getBaseColorOf', () => {
|
|
16
|
+
it('maps each colour enum to a Tailwind colour name', () => {
|
|
17
|
+
expect(getBaseColorOf(ColorsEnums.GREEN)).toBe('green')
|
|
18
|
+
expect(getBaseColorOf(ColorsEnums.RED)).toBe('red')
|
|
19
|
+
expect(getBaseColorOf(ColorsEnums.BLUE)).toBe('blue')
|
|
20
|
+
expect(getBaseColorOf(ColorsEnums.YELLOW)).toBe('yellow')
|
|
21
|
+
expect(getBaseColorOf(ColorsEnums.BLACK)).toBe('black')
|
|
22
|
+
expect(getBaseColorOf(ColorsEnums.GRAY)).toBe('gray')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('returns an empty string for NONE', () => {
|
|
26
|
+
expect(getBaseColorOf(ColorsEnums.NONE)).toBe('')
|
|
27
|
+
})
|
|
28
|
+
})
|