diff --git a/src/features/cx/components/CXVolumeShare.vue b/src/features/cx/components/CXVolumeShare.vue new file mode 100644 index 000000000..5eb9b11a3 --- /dev/null +++ b/src/features/cx/components/CXVolumeShare.vue @@ -0,0 +1,132 @@ + + + diff --git a/src/features/cx/cxVolumeShare.ts b/src/features/cx/cxVolumeShare.ts new file mode 100644 index 000000000..e46db313e --- /dev/null +++ b/src/features/cx/cxVolumeShare.ts @@ -0,0 +1,165 @@ +// Types & Interfaces +import { EXCHANGES_TYPE } from "@/database/services/useExchangeData.types"; +import { + CX_VOLUME_LEVEL, + ICXVolumeShare, + ICXVolumeThresholds, + ICXVolumeWindow, +} from "@/features/cx/cxVolumeShare.types"; + +/** Share of daily traded volume, in percent, that turns a row yellow */ +export const CX_VOLUME_YELLOW_PERCENT: number = 5; +/** Share of daily traded volume, in percent, that turns a row red */ +export const CX_VOLUME_RED_PERCENT: number = 15; + +/** + * Units traded over 7 days at or below which the exchange counts as + * illiquid: any sale worth mentioning moves a market this thin, so it is + * warned about directly instead of through a share whose denominator is + * meaningless — or zero — down here + */ +export const CX_VOLUME_ILLIQUID_7D: number = 10; + +/** Units per week below which a sale never warns at all */ +export const CX_VOLUME_MIN_WEEKLY_SOLD: number = 1; + +/** Every exchange the game data carries a traded volume for */ +export const CX_VOLUME_EXCHANGES: EXCHANGES_TYPE[] = [ + "AI1", + "CI1", + "IC1", + "NC1", + "UNIVERSE", +]; + +/** + * Calculates one traded volume window measured against a daily sale + * @author raukk + * + * @export + * @param {number} soldPerDay Units per day sold to the exchange + * @param {number} sumTraded Units traded over the whole window + * @param {number} days Length of the window in days + * @returns {ICXVolumeWindow} Window with its share + */ +export function volumeWindow( + soldPerDay: number, + sumTraded: number, + days: number +): ICXVolumeWindow { + const perDay: number = sumTraded / days; + + return { + sumTraded, + days, + share: perDay > 0 ? soldPerDay / perDay : undefined, + }; +} + +/** + * Severity a single share carries on its own + * @author raukk + * + * @export + * @param {number | undefined} share Share of daily traded volume + * @param {ICXVolumeThresholds} thresholds Colour thresholds in percent + * @returns {CX_VOLUME_LEVEL} Severity + */ +export function levelOfShare( + share: number | undefined, + thresholds: ICXVolumeThresholds +): CX_VOLUME_LEVEL { + if (share === undefined) return "none"; + + const percent: number = share * 100; + + if (percent >= thresholds.redPercent) return "red"; + if (percent >= thresholds.yellowPercent) return "yellow"; + + return "none"; +} + +/** + * The worse of two severities + * @author raukk + * + * @export + * @param {CX_VOLUME_LEVEL} a First severity + * @param {CX_VOLUME_LEVEL} b Second severity + * @returns {CX_VOLUME_LEVEL} Worse of the two + */ +export function worstLevel( + a: CX_VOLUME_LEVEL, + b: CX_VOLUME_LEVEL +): CX_VOLUME_LEVEL { + if (a === "red" || b === "red") return "red"; + if (a === "yellow" || b === "yellow") return "yellow"; + + return "none"; +} + +/** Traded sums of one ticker on the exchange the sale lands on */ +export interface ICXVolumeSums { + sumTraded7d: number; + sumTraded30d: number; +} + +/** + * Calculates a material row's pressure on the exchange it is sold at. + * Both windows are computed and the worse of the two colours the row: + * 7d catches the market as it stands, 30d catches a ticker that has + * just gone quiet and whose 7d window is no longer representative + * @author raukk + * + * @export + * @param {string} ticker Material ticker + * @param {EXCHANGES_TYPE} exchange Exchange the row is sold at + * @param {number} soldPerDay Units per day reaching that exchange + * @param {ICXVolumeSums} sums Traded sums of the ticker + * @param {ICXVolumeThresholds} thresholds Colour thresholds in percent + * @returns {ICXVolumeShare} Shares and severity of the row + */ +export function calculateCXVolumeShare( + ticker: string, + exchange: EXCHANGES_TYPE, + soldPerDay: number, + sums: ICXVolumeSums, + thresholds: ICXVolumeThresholds +): ICXVolumeShare { + const window7d: ICXVolumeWindow = volumeWindow( + soldPerDay, + sums.sumTraded7d, + 7 + ); + const window30d: ICXVolumeWindow = volumeWindow( + soldPerDay, + sums.sumTraded30d, + 30 + ); + + // too small a sale to warn about, whatever the exchange looks like + const worthWarning: boolean = + soldPerDay > 0 && soldPerDay * 7 >= CX_VOLUME_MIN_WEEKLY_SOLD; + + const illiquid: boolean = + worthWarning && sums.sumTraded7d < CX_VOLUME_ILLIQUID_7D; + + const level: CX_VOLUME_LEVEL = !worthWarning + ? "none" + : illiquid + ? "red" + : worstLevel( + levelOfShare(window7d.share, thresholds), + levelOfShare(window30d.share, thresholds) + ); + + return { + ticker, + exchange, + soldPerDay, + window7d, + window30d, + illiquid, + level, + }; +} diff --git a/src/features/cx/cxVolumeShare.types.ts b/src/features/cx/cxVolumeShare.types.ts new file mode 100644 index 000000000..3eac4b7ea --- /dev/null +++ b/src/features/cx/cxVolumeShare.types.ts @@ -0,0 +1,36 @@ +import { EXCHANGES_TYPE } from "@/database/services/useExchangeData.types"; + +/** Severity of a row's share of the exchange's traded volume */ +export type CX_VOLUME_LEVEL = "none" | "yellow" | "red"; + +/** Shares of daily traded volume, in percent, that colour a row */ +export interface ICXVolumeThresholds { + yellowPercent: number; + redPercent: number; +} + +/** One traded volume window of an exchange, measured against a sale */ +export interface ICXVolumeWindow { + sumTraded: number; + days: number; + /** soldPerDay / (sumTraded / days), undefined while nothing trades */ + share: number | undefined; +} + +/** A single material row's pressure on the exchange it is sold at */ +export interface ICXVolumeShare { + ticker: string; + exchange: EXCHANGES_TYPE; + soldPerDay: number; + window7d: ICXVolumeWindow; + window30d: ICXVolumeWindow; + /** Exchange trades too little for any share to be meaningful */ + illiquid: boolean; + level: CX_VOLUME_LEVEL; +} + +/** Input of a volume share calculation, one per material row */ +export interface ICXVolumeRow { + ticker: string; + soldPerDay: number; +} diff --git a/src/features/cx/useCXVolumeShare.ts b/src/features/cx/useCXVolumeShare.ts new file mode 100644 index 000000000..e5a3d96e2 --- /dev/null +++ b/src/features/cx/useCXVolumeShare.ts @@ -0,0 +1,144 @@ +import { Ref, ref, watchEffect } from "vue"; + +// Stores +import { usePlanningStore } from "@/stores/planningStore"; + +// Composables +import { useExchangeData } from "@/database/services/useExchangeData"; +import { usePreferences } from "@/features/preferences/usePreferences"; + +// Calculation Utils +import { + CX_VOLUME_EXCHANGES, + calculateCXVolumeShare, +} from "@/features/cx/cxVolumeShare"; + +// Types & Interfaces +import { EXCHANGES_TYPE } from "@/database/services/useExchangeData.types"; +import { IExchange } from "@/features/api/gameData.types"; +import { ICXData } from "@/stores/planningStore.types"; +import { + ICXVolumeRow, + ICXVolumeShare, + ICXVolumeThresholds, +} from "@/features/cx/cxVolumeShare.types"; + +/** + * Resolves the exchange a CX configuration sells at from its empire + * exchange preference. Anything unresolvable measures against the + * universe, which is never wrong, only less specific + * @author raukk + * + * @export + * @param {string | undefined} cxUuid CX configuration uuid + * @returns {EXCHANGES_TYPE} Exchange the surplus lands on + */ +export function resolveSellExchange( + cxUuid: string | undefined +): EXCHANGES_TYPE { + if (!cxUuid) return "UNIVERSE"; + + try { + const cxData: ICXData = usePlanningStore().getCX(cxUuid).cx_data; + + // a "BOTH" preference stands in for the missing "SELL" one, the + // backend forbids holding both at once + const preference = cxData.cx_empire.find( + (entry) => entry.type === "SELL" || entry.type === "BOTH" + ); + + if (!preference) return "UNIVERSE"; + + // preference codes are `_`, e.g. "AI1_30D" + const code = preference.exchange.split("_")[0] as EXCHANGES_TYPE; + + return CX_VOLUME_EXCHANGES.includes(code) ? code : "UNIVERSE"; + } catch { + return "UNIVERSE"; + } +} + +/** + * Keeps a ticker keyed map of volume shares in step with the material + * rows handed in + * @author raukk + * + * @export + * @param {Ref} rows Material rows and their daily sales + * @param {Ref} cxUuid CX configuration of the plan + * @returns {{ volumeShares: Ref> }} Shares + */ +export function useCXVolumeShare( + rows: Ref, + cxUuid: Ref +): { volumeShares: Ref> } { + const { cxVolumeYellowPercent, cxVolumeRedPercent } = usePreferences(); + + const volumeShares: Ref> = ref(new Map()); + + // guards against an earlier, slower run overwriting a later one + let generation: number = 0; + + async function computeShares( + localRows: ICXVolumeRow[], + exchange: EXCHANGES_TYPE, + thresholds: ICXVolumeThresholds, + run: number + ): Promise { + const { getExchangeTicker } = await useExchangeData(); + + const next: Map = new Map(); + + await Promise.all( + localRows.map(async (row) => { + try { + const data: IExchange = await getExchangeTicker( + `${row.ticker}.${exchange}` + ); + + next.set( + row.ticker, + calculateCXVolumeShare( + row.ticker, + exchange, + row.soldPerDay, + { + sumTraded7d: data.sum_traded_7d, + sumTraded30d: data.sum_traded_30d, + }, + thresholds + ) + ); + } catch { + // no exchange record for the ticker, nothing to warn about + } + }) + ); + + if (run === generation) volumeShares.value = next; + } + + // the effect itself stays synchronous, so every reactive read is + // tracked; the async fetch runs detached under a generation guard + watchEffect(() => { + const localRows: ICXVolumeRow[] = rows.value.filter( + (row) => row.soldPerDay > 0 + ); + const exchange: EXCHANGES_TYPE = resolveSellExchange(cxUuid.value); + const thresholds: ICXVolumeThresholds = { + yellowPercent: cxVolumeYellowPercent.value, + redPercent: cxVolumeRedPercent.value, + }; + + const run: number = ++generation; + + if (localRows.length === 0) { + volumeShares.value = new Map(); + return; + } + + computeShares(localRows, exchange, thresholds, run).catch(() => {}); + }); + + return { volumeShares }; +} diff --git a/src/features/planning/components/PlanMaterialIO.vue b/src/features/planning/components/PlanMaterialIO.vue index fd3f8497e..062b1ce3c 100644 --- a/src/features/planning/components/PlanMaterialIO.vue +++ b/src/features/planning/components/PlanMaterialIO.vue @@ -6,9 +6,14 @@ // Components import MaterialTile from "@/features/material_tile/components/MaterialTile.vue"; + import CXVolumeShare from "@/features/cx/components/CXVolumeShare.vue"; + + // Composables + import { useCXVolumeShare } from "@/features/cx/useCXVolumeShare"; // Types & Interfaces import { IMaterialIO } from "@/features/planning/usePlanCalculation.types"; + import { ICXVolumeRow } from "@/features/cx/cxVolumeShare.types"; // Util import { formatNumber } from "@/util/numbers"; @@ -25,6 +30,11 @@ type: Boolean, required: true, }, + cxUuid: { + type: String, + required: false, + default: undefined, + }, }); // Local State @@ -34,6 +44,19 @@ const localShowBasked: ComputedRef = computed( () => props.showBasked ); + const localCXUuid: ComputedRef = computed( + () => props.cxUuid + ); + + // units per day each output row sells, its own consumption is + // already netted into the delta + const localVolumeRows: ComputedRef = computed(() => + localMaterialIOData.value + .filter((row) => row.delta > 0) + .map((row) => ({ ticker: row.ticker, soldPerDay: row.delta })) + ); + + const { volumeShares } = useCXVolumeShare(localVolumeRows, localCXUuid); userStore.setPreference("burnOrigin", v), }); + const cxVolumeYellowPercent: WritableComputedRef = + computed({ + get: () => + userStore.preferences.cxVolumeYellowPercent ?? + CX_VOLUME_YELLOW_PERCENT, + set: (v) => userStore.setPreference("cxVolumeYellowPercent", v), + }); + + const cxVolumeRedPercent: WritableComputedRef = + computed({ + get: () => + userStore.preferences.cxVolumeRedPercent ?? + CX_VOLUME_RED_PERCENT, + set: (v) => userStore.setPreference("cxVolumeRedPercent", v), + }); + const planSettings: ComputedRef< Record> > = computed(() => { @@ -234,6 +254,8 @@ export function usePreferences() { burnDaysYellow, burnResupplyDays, burnOrigin, + cxVolumeYellowPercent, + cxVolumeRedPercent, planSettings, planSettingsOverview, layoutNavigationStyle, diff --git a/src/features/preferences/userDefaults.ts b/src/features/preferences/userDefaults.ts index 3405be1ab..a128f26a1 100644 --- a/src/features/preferences/userDefaults.ts +++ b/src/features/preferences/userDefaults.ts @@ -1,4 +1,8 @@ import { IPreferenceDefault } from "@/features/preferences/userPreferences.types"; +import { + CX_VOLUME_RED_PERCENT, + CX_VOLUME_YELLOW_PERCENT, +} from "@/features/cx/cxVolumeShare"; /** * Defines default values for user preferences, contains generic tool @@ -18,6 +22,9 @@ export const preferenceDefaults: IPreferenceDefault = { burnResupplyDays: 20, burnOrigin: "Configure on Execution", layoutNavigationStyle: "full", + // CX volume warning thresholds, see cxVolumeShare.ts + cxVolumeYellowPercent: CX_VOLUME_YELLOW_PERCENT, + cxVolumeRedPercent: CX_VOLUME_RED_PERCENT, planOverrides: {}, planDefaults: { diff --git a/src/features/preferences/userPreferences.types.ts b/src/features/preferences/userPreferences.types.ts index f744d4d59..bc3f8446e 100644 --- a/src/features/preferences/userPreferences.types.ts +++ b/src/features/preferences/userPreferences.types.ts @@ -17,6 +17,15 @@ export interface IPreference { burnOrigin: string; layoutNavigationStyle: "full" | "collapsed"; + /** + * Share of an exchange's daily traded volume, in percent, at which a + * plan's sale of a material is flagged yellow respectively red. + * Optional and client side only: deliberately absent from + * `UserPreferenceSchema`, so it never reaches the backend + */ + cxVolumeYellowPercent?: number; + cxVolumeRedPercent?: number; + // seeding per plan defaults planOverrides: Record>; diff --git a/src/features/profile/components/UserPreferences.vue b/src/features/profile/components/UserPreferences.vue index e7dfbe22d..c194809de 100644 --- a/src/features/profile/components/UserPreferences.vue +++ b/src/features/profile/components/UserPreferences.vue @@ -37,6 +37,8 @@ burnDaysYellow, burnResupplyDays, burnOrigin, + cxVolumeYellowPercent, + cxVolumeRedPercent, locale, planSettingsOverview, cleanPlanPreferences, @@ -174,6 +176,22 @@ + + + + + +

diff --git a/src/locales/en_US/cx_volume.json b/src/locales/en_US/cx_volume.json new file mode 100644 index 000000000..d5bc46f8f --- /dev/null +++ b/src/locales/en_US/cx_volume.json @@ -0,0 +1,11 @@ +{ + "share": "{percent}% of {exchange} 7D vol", + "share_no_volume": "no {exchange} 7D volume", + "illiquid": "{exchange} barely trades", + "tooltip_intro": "Selling {units} / day of {ticker} into {exchange}, net of what the plan consumes itself.", + "tooltip_window": "{window} at {exchange}: {traded} units traded ({perDay} / day) — your sale is {percent}% of it.", + "tooltip_window_empty": "{window} at {exchange}: nothing traded.", + "tooltip_illiquid": "{exchange} traded {traded} units of {ticker} in 7 days.", + "preferences_yellow": "CX Volume Warning (Yellow, %)", + "preferences_red": "CX Volume Warning (Red, %)" +} diff --git a/src/tests/features/cx/cxVolumeShare.test.ts b/src/tests/features/cx/cxVolumeShare.test.ts new file mode 100644 index 000000000..ee3a6b4f1 --- /dev/null +++ b/src/tests/features/cx/cxVolumeShare.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; + +import { + CX_VOLUME_ILLIQUID_7D, + calculateCXVolumeShare, + levelOfShare, + volumeWindow, + worstLevel, +} from "@/features/cx/cxVolumeShare"; + +// Types & Interfaces +import { ICXVolumeThresholds } from "@/features/cx/cxVolumeShare.types"; + +const thresholds: ICXVolumeThresholds = { yellowPercent: 5, redPercent: 15 }; + +describe("cxVolumeShare", () => { + describe("volumeWindow", () => { + it("measures a sale against the windows daily volume", () => { + // 70 over 7 days = 10 / day, selling 1 / day = 10% + expect(volumeWindow(1, 70, 7).share).toBeCloseTo(0.1, 8); + }); + + it("carries no share while nothing trades", () => { + expect(volumeWindow(1, 0, 7).share).toBeUndefined(); + }); + }); + + describe("levelOfShare", () => { + it("colours by the thresholds", () => { + expect(levelOfShare(0.01, thresholds)).toBe("none"); + expect(levelOfShare(0.05, thresholds)).toBe("yellow"); + expect(levelOfShare(0.15, thresholds)).toBe("red"); + expect(levelOfShare(undefined, thresholds)).toBe("none"); + }); + }); + + describe("worstLevel", () => { + it("never disagrees in the users favour", () => { + expect(worstLevel("none", "red")).toBe("red"); + expect(worstLevel("yellow", "none")).toBe("yellow"); + expect(worstLevel("none", "none")).toBe("none"); + }); + }); + + describe("calculateCXVolumeShare", () => { + it("colours by the worse of the 7d and 30d window", () => { + // 7d fine (1%), 30d red (20%): the market just went quiet + const share = calculateCXVolumeShare( + "RAT", + "AI1", + 1, + { sumTraded7d: 700, sumTraded30d: 150 }, + thresholds + ); + + expect(share.window7d.share).toBeCloseTo(0.01, 8); + expect(share.window30d.share).toBeCloseTo(0.2, 8); + expect(share.level).toBe("red"); + }); + + it("flags a barely trading exchange as illiquid", () => { + const share = calculateCXVolumeShare( + "RAT", + "AI1", + 1, + { sumTraded7d: CX_VOLUME_ILLIQUID_7D - 1, sumTraded30d: 100 }, + thresholds + ); + + expect(share.illiquid).toBe(true); + expect(share.level).toBe("red"); + }); + + it("never warns about a negligible sale", () => { + const share = calculateCXVolumeShare( + "RAT", + "AI1", + 0.1, + { sumTraded7d: 0, sumTraded30d: 0 }, + thresholds + ); + + expect(share.illiquid).toBe(false); + expect(share.level).toBe("none"); + }); + + it("respects custom thresholds", () => { + // 10% share, strict user reds at 8% + const share = calculateCXVolumeShare( + "RAT", + "AI1", + 1, + { sumTraded7d: 70, sumTraded30d: 300 }, + { yellowPercent: 2, redPercent: 8 } + ); + + expect(share.level).toBe("red"); + }); + }); +}); diff --git a/src/views/PlanView.vue b/src/views/PlanView.vue index 1e3bf918f..4592ad453 100644 --- a/src/views/PlanView.vue +++ b/src/views/PlanView.vue @@ -953,7 +953,8 @@