scan front

This commit is contained in:
Sirttas
2026-06-11 20:32:46 +02:00
parent dd031551ca
commit 7ca38aee70
11 changed files with 155 additions and 175 deletions
+2
View File
@@ -6,6 +6,7 @@ import {
CharacterApi,
CharacterRuleBookApi,
LedgerApi,
MarketApi,
ProcessingApi,
RuleBookApi,
TransactionApi
@@ -31,3 +32,4 @@ export const characterRuleBookApi = new CharacterRuleBookApi(undefined, mammonUr
export const activityApi = new ActivityApi(undefined, mammonUrl, mammonAxiosInstance);
export const processingApi = new ProcessingApi(undefined, mammonUrl, mammonAxiosInstance);
export const acquisitionApi = new AcquisitionApi(undefined, mammonUrl, mammonAxiosInstance);
export const marketApi = new MarketApi(undefined, mammonUrl, mammonAxiosInstance);
@@ -1,12 +1,13 @@
<script setup lang="ts">
import {SliderCheckbox} from '@/components';
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
import {getHistoryQuartils, MarketType, MarketTypeLabel, TaxInput, useMarketTaxStore} from "@/market";
import {BookmarkSlashIcon, ShoppingCartIcon} from '@heroicons/vue/24/outline';
import {formatIsk, percentFormater} from "@/formaters";
import {MarketType, MarketTypeLabel} from "@/market";
import {ShoppingCartIcon} from '@heroicons/vue/24/outline';
import {useStorage} from '@vueuse/core';
import {computed, ref} from 'vue';
import {useAcquiredTypesStore} from '../acquisition';
import {TrackingResult} from './tracking';
import {ScanResult} from './scan';
type Result = {
type: MarketType;
@@ -17,25 +18,28 @@ type Result = {
q1: number;
median: number;
q3: number;
totalVolume: number;
acquisitions: number;
profit: number;
score: number;
}
interface Props {
items?: TrackingResult[];
items?: ScanResult[];
infoOnly?: boolean;
ignoredColums?: string[] | string;
}
interface Emits {
(e: 'buy', type: MarketType, buy: number, sell: number): void;
(e: 'remove', type: MarketType): void;
}
const scoreFormater = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0
});
const volumeFormater = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0
});
const props = withDefaults(defineProps<Props>(), {
items: () => [],
@@ -44,11 +48,9 @@ const props = withDefaults(defineProps<Props>(), {
});
defineEmits<Emits>();
const marketTaxStore = useMarketTaxStore();
const acquiredTypesStore = useAcquiredTypesStore();
const days = useStorage('market-tracking-days', 365);
const threshold = useStorage('market-tracking-threshold', 10);
const threshold = useStorage('market-scan-threshold', 10);
const filter = ref("");
const onlyCheap = ref(false);
const columnsToIgnore = computed(() => {
@@ -62,9 +64,6 @@ const columnsToIgnore = computed(() => {
const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() => props.items
.filter(r => r.type.name.toLowerCase().includes(filter.value.toLowerCase()))
.map(r => {
const quartils = getHistoryQuartils(r.history, days.value);
const profit = quartils.q1 === 0 || quartils.q3 === 0 ? 0 : marketTaxStore.calculateProfit(quartils.q1, quartils.q3);
const score = profit <= 0 ? 0 : Math.sqrt((Math.pow(quartils.totalVolume, 1.1) * Math.pow(quartils.q1, 1.2) * Math.pow(profit, 0.5) * Math.pow(Math.max(1, r.orderCount), -0.7)) / days.value);
const acquisitions = columnsToIgnore.value.includes('acquisitions') ? 0 : acquiredTypesStore.acquiredTypes
.filter(t => t.type === r.type.id)
.reduce((a, b) => a + b.remaining, 0);
@@ -75,12 +74,13 @@ const { sortedArray, headerProps, showColumn } = useSort<Result>(computed(() =>
name: r.type.name,
buy: r.buy,
sell: r.sell,
q1: quartils.q1,
median: quartils.median,
q3: quartils.q3,
q1: r.q1,
median: r.median,
q3: r.q3,
totalVolume: r.totalVolume,
acquisitions,
profit,
score
profit: r.profit,
score: r.score
};
}).filter(r => !onlyCheap.value || (r.buy <= r.q1 && r.profit >= (threshold.value / 100)))), {
defaultSortKey: 'score',
@@ -104,15 +104,10 @@ const getLineColor = (result: Result) => {
<template>
<div v-if="!infoOnly" class="flex mb-2 mt-4">
<div class="flex justify-self-end ms-auto">
<TaxInput />
<div class="end">
<span>Profit Threshold: </span>
<input type="number" min="0" max="1000" step="1" v-model="threshold" />
</div>
<div class="end">
<span>Days: </span>
<input type="number" min="1" max="365" step="1" v-model="days" />
</div>
<div class="end flex">
<SliderCheckbox class="me-1" v-model="onlyCheap" /> Show only cheap items
</div>
@@ -132,6 +127,7 @@ const getLineColor = (result: Result) => {
<SortableHeader v-bind="headerProps" sortKey="q1">Q1</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="median">Median</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="q3">Q3</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="totalVolume">Volume</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="profit">Profit</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="score">Score</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="acquisitions">Acquisitions</SortableHeader>
@@ -148,12 +144,12 @@ const getLineColor = (result: Result) => {
<td v-if="showColumn('q1')" class="text-right">{{ formatIsk(r.data.q1) }}</td>
<td v-if="showColumn('median')" class="text-right">{{ formatIsk(r.data.median) }}</td>
<td v-if="showColumn('q3')" class="text-right">{{ formatIsk(r.data.q3) }}</td>
<td v-if="showColumn('totalVolume')" class="text-right">{{ volumeFormater.format(r.data.totalVolume) }}</td>
<td v-if="showColumn('profit')" class="text-right">{{ percentFormater.format(r.data.profit) }}</td>
<td v-if="showColumn('score')" class="text-right">{{ scoreFormater.format(r.data.score) }}</td>
<td v-if="showColumn('acquisitions')" class="text-right">{{ r.data.acquisitions }}</td>
<td v-if="showColumn('buttons')" class="text-right">
<button class="btn-icon me-1" title="Add acquisitions" @click="$emit('buy', r.data.type, r.data.buy, r.data.sell)"><ShoppingCartIcon /></button>
<button class="btn-icon me-1" title="Untrack" @click="$emit('remove', r.data.type)"><BookmarkSlashIcon /></button>
</td>
</tr>
</tbody>
@@ -171,4 +167,4 @@ const getLineColor = (result: Result) => {
div.end {
@apply justify-self-end ms-2;
}
</style>../history/HistoryQuartils
</style>
+3
View File
@@ -0,0 +1,3 @@
export * from './scan';
export { default as ScanResultTable } from './ScanResultTable.vue';
+53
View File
@@ -0,0 +1,53 @@
import { getHistory, getHistoryQuartils, HistoryQuartils, MarketType, MarketTypePrice } from "@/market";
import { MarketScanResponse } from "@/generated/mammon";
export type ScanResult = {
type: MarketType;
buy: number;
sell: number;
q1: number;
median: number;
q3: number;
totalVolume: number;
profit: number;
score: number;
}
// Mirrors mammon's MarketScoreCalculator so the client-side path matches the backend scan.
export const calculateScore = (quartils: HistoryQuartils, profit: number, orderCount: number, days: number): number => {
if (profit <= 0) {
return 0;
}
return Math.sqrt((Math.pow(quartils.totalVolume, 1.1) * Math.pow(quartils.q1, 1.2) * Math.pow(profit, 0.5) * Math.pow(Math.max(1, orderCount), -0.7)) / days);
}
export const toScanResult = (res: MarketScanResponse, type: MarketType, price: MarketTypePrice): ScanResult => ({
type,
buy: price.buy,
sell: price.sell,
q1: res.q1,
median: res.median,
q3: res.q3,
totalVolume: res.totalVolume,
profit: res.profit,
score: res.score,
});
// Client-side scan result for a single type (used where the scan endpoint can't be queried per-type).
export const buildScanResult = async (price: MarketTypePrice, days: number, calculateProfit: (buy: number, sell: number) => number): Promise<ScanResult> => {
const history = await getHistory(price.type.id);
const quartils = getHistoryQuartils(history, days);
const profit = quartils.q1 === 0 || quartils.q3 === 0 ? 0 : calculateProfit(quartils.q1, quartils.q3);
return {
type: price.type,
buy: price.buy,
sell: price.sell,
q1: quartils.q1,
median: quartils.median,
q3: quartils.q3,
totalVolume: quartils.totalVolume,
profit,
score: calculateScore(quartils, profit, price.orderCount, days),
};
}
-4
View File
@@ -1,4 +0,0 @@
export * from './tracking';
export { default as TrackingResultTable } from './TrackingResultTable.vue';
-40
View File
@@ -1,40 +0,0 @@
import { EsiMarketOrderHistory, getHistory, MarketType, MarketTypePrice } from "@/market";
import log from "loglevel";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
export type TrackingResult = {
type: MarketType;
history: EsiMarketOrderHistory[];
buy: number,
sell: number,
orderCount: number,
}
const endpoint = '/api/types_tracking/';
export const useMarketTrackingStore = defineStore('marketTracking', () => {
const trackedTypes = ref<any[]>([]); // TODO
const types = computed(() => trackedTypes.value.map(item => item.type) ?? []);
const addType = async (type: number) => {
const found = trackedTypes.value.find(item => item.type === type);
if (!found) {
log.info(`Tracking type ${type}`);
}
}
const removeType = async (type: number) => {
const found = trackedTypes.value.find(item => item.type === type);
if (!found) {
return;
}
trackedTypes.value = trackedTypes.value.filter(t => t.id !== found.id);
}
return { types, addType, removeType };
});
export const createResult = async (id: number, price: MarketTypePrice): Promise<TrackingResult> => ({ history: await getHistory(id), ...price });
+2 -2
View File
@@ -9,8 +9,8 @@ import {routeNames} from '@/routes';
<RouterLink :to="{name: routeNames.marketTypes}" class="tab">
<span>Item Info</span>
</RouterLink>
<RouterLink to="/market/tracking" class="tab">
<span>Tracking</span>
<RouterLink to="/market/scan" class="tab">
<span>Scan</span>
</RouterLink>
<RouterLink to="/market/acquisitions" class="tab">
<span>Acquisitions</span>
+67
View File
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { getMarketTypes, TaxInput, useApraisalStore, useMarketTaxStore } from "@/market";
import { BuyModal } from '@/market/acquisition';
import { ScanResult, ScanResultTable, toScanResult } from '@/market/scan';
import { marketApi } from "@/mammon";
import { useStorage } from "@vueuse/core";
import { ref, watch } from 'vue';
const buyModal = ref<typeof BuyModal>();
const apraisalStore = useApraisalStore();
const marketTaxStore = useMarketTaxStore();
const days = useStorage('market-scan-days', 365);
const items = ref<ScanResult[]>([]);
const loading = ref(false);
const scan = async () => {
loading.value = true;
try {
const { data } = await marketApi.scanMarket(
String(days.value),
String(marketTaxStore.brokerFee / 100),
String(marketTaxStore.scc / 100)
);
const types = await getMarketTypes(data.map(r => r.marketTypeId));
const prices = await apraisalStore.getPrices(types);
items.value = data.flatMap(r => {
const type = types.find(t => t.id === r.marketTypeId);
const price = prices.find(p => p.type.id === r.marketTypeId);
return type && price ? [toScanResult(r, type, price)] : [];
});
} finally {
loading.value = false;
}
}
watch([days, () => marketTaxStore.brokerFee, () => marketTaxStore.scc], scan, { immediate: true });
</script>
<template>
<div class="flex mb-2 mt-4">
<div class="flex justify-self-end ms-auto">
<TaxInput />
<div class="end">
<span>Days: </span>
<input type="number" min="1" max="365" step="1" v-model="days" />
</div>
</div>
</div>
<hr />
<div v-if="loading" class="text-center mt-4">
<span>Scanning market</span>
</div>
<template v-else>
<ScanResultTable :items="items" @buy="(type, buy, sell) => buyModal?.open(type, { 'Buy': buy, 'Sell': sell })" />
<BuyModal ref="buyModal" />
</template>
</template>
<style scoped>
@reference "@/style.css";
div.end {
@apply justify-self-end ms-2;
}
</style>
-82
View File
@@ -1,82 +0,0 @@
<script setup lang="ts">
import { Modal, ProgressBar } from "@/components";
import { MarketType, MarketTypeInput, MarketTypePrice, getHistory, getMarketTypes, useApraisalStore } from "@/market";
import { BuyModal } from '@/market/acquisition';
import { TrackingResult, TrackingResultTable, createResult, useMarketTrackingStore } from '@/market/tracking';
import { ref, watch } from 'vue';
const buyModal = ref<typeof BuyModal>();
const item = ref<MarketType>();
const apraisalStore = useApraisalStore();
const marketTrackingStore = useMarketTrackingStore();
const items = ref<TrackingResult[]>([]);
const addOrRelaod = async (type: MarketType) => {
const typeID = type.id;
const [history, price] = await Promise.all([
getHistory(typeID),
apraisalStore.getPrice(type)
]);
const itm = {
type,
history,
buy: price.buy,
sell: price.sell,
orderCount: price.orderCount
};
if (items.value.some(i => i.type.id === typeID)) {
items.value = items.value.map(i => i.type.id === typeID ? itm : i);
} else {
items.value = [ ...items.value, itm];
marketTrackingStore.addType(typeID);
}
}
const addItem = async () => {
if (!item.value) {
// TODO error
return;
}
addOrRelaod(item.value);
item.value = undefined;
}
const removeItem = (type: MarketType) => {
items.value = items.value.filter(i => i.type.id !== type.id);
marketTrackingStore.removeType(type.id);
}
watch(() => marketTrackingStore.types, async t => {
const typesToLoad = t.filter(t => !items.value.some(i => i.type.id === t));
if (typesToLoad.length === 0) {
return;
}
const prices = await apraisalStore.getPrices(await getMarketTypes(typesToLoad));
typesToLoad.forEach(async i => items.value.push(await createResult(i, prices.find(p => p.type.id === i) as MarketTypePrice)));
}, { immediate: true });
</script>
<template>
<div class="grid mb-2 mt-4">
<div class="w-auto flex">
<span>Item: </span>
<MarketTypeInput class="ms-2" v-model="item" @submit="addItem"/>
<button class="justify-self-end ms-2" @click="addItem">Add</button>
</div>
</div>
<template v-if="items.length > 0">
<hr />
<TrackingResultTable :items="items" @buy="(type, buy, sell) => buyModal?.open(type, { 'Buy': buy, 'Sell': sell })" @remove="removeItem" />
<BuyModal ref="buyModal" />
<Modal :open="items.length > 0 && items.length < marketTrackingStore.types.length">
<div class="ms-auto me-auto mb-2 w-96">
<ProgressBar :value="items.length" :total="marketTrackingStore.types.length" />
</div>
</Modal>
</template>
</template>
+8 -23
View File
@@ -1,14 +1,14 @@
<script setup lang="ts">
import {ClipboardButton} from '@/components';
import {getMarketType, MarketType, MarketTypeInput, useApraisalStore} from "@/market";
import {getMarketType, MarketType, MarketTypeInput, useApraisalStore, useMarketTaxStore} from "@/market";
import {AcquisitionResultTable, BuyModal, useAcquiredTypesStore} from '@/market/acquisition';
import {createResult, TrackingResultTable, useMarketTrackingStore} from '@/market/tracking';
import {BookmarkIcon, BookmarkSlashIcon, ShoppingCartIcon} from '@heroicons/vue/24/outline';
import {buildScanResult, ScanResultTable} from '@/market/scan';
import {ShoppingCartIcon} from '@heroicons/vue/24/outline';
import log from "loglevel";
import {computed, ref, watch} from "vue";
import {useRoute, useRouter} from "vue-router";
import {routeNames} from "@/routes";
import {computedAsync} from "@vueuse/core";
import {computedAsync, useStorage} from "@vueuse/core";
const buyModal = ref<typeof BuyModal>();
@@ -18,12 +18,12 @@ const item = ref<MarketType>();
const inputItem = ref<MarketType>();
const apraisalStore = useApraisalStore();
const marketTaxStore = useMarketTaxStore();
const days = useStorage('market-scan-days', 365);
const price = computedAsync(() => item.value ? apraisalStore.getPrice(item.value) : undefined);
const marketTrackingStore = useMarketTrackingStore();
const result = computedAsync(async () => item.value && price.value ? await createResult(item.value?.id, price.value) : undefined);
const result = computedAsync(async () => price.value ? await buildScanResult(price.value, days.value, marketTaxStore.calculateProfit) : undefined);
const acquiredTypesStore = useAcquiredTypesStore();
const isTracked = computed(() => item.value ? marketTrackingStore.types.includes(item.value.id) : false);
const acquisitions = computed(() => {
const p = price.value;
@@ -36,17 +36,6 @@ const acquisitions = computed(() => {
sell: p.sell
}));
});
const toogleTracking = () => {
if (!item.value) {
return;
}
if (isTracked.value) {
marketTrackingStore.removeType(item.value.id);
} else {
marketTrackingStore.addType(item.value.id);
}
}
const view = () => {
if (!inputItem.value) {
return;
@@ -93,10 +82,6 @@ watch(useRoute(), async route => {
<div class="ms-auto">
<ClipboardButton class="ms-1" :value="item.name" />
<button v-if="price" class="btn-icon ms-1" title="Add acquisitions" @click="buyModal?.open(item, { 'Buy': price.buy, 'Sell': price.sell })"><ShoppingCartIcon /></button>
<button class="btn-icon ms-1" :title="isTracked ? 'Untrack' : 'Track'" @click="toogleTracking">
<BookmarkSlashIcon v-if="isTracked" />
<BookmarkIcon v-else />
</button>
</div>
</div>
<p v-if="item.description" class="text-sm">{{ item.description }}</p>
@@ -104,7 +89,7 @@ watch(useRoute(), async route => {
</div>
<div v-if="result" class="mb-4">
<span>Market Info:</span>
<TrackingResultTable :items="[result]" infoOnly :ignoredColums="['name', 'acquisitions']" />
<ScanResultTable :items="[result]" infoOnly :ignoredColums="['name', 'acquisitions']" />
</div>
<div v-if="acquisitions && acquisitions.length > 0">
<span>Acquisitions:</span>
+1 -1
View File
@@ -43,7 +43,7 @@ export const routes: RouteRecordRaw[] = [
{path: '/market', component: () => import('@/pages/Market.vue'), children: [
{path: '', redirect: {name: routeNames.marketTypes}},
{path: 'types/:type?', name: routeNames.marketTypes, component: () => import('@/pages/market/TypeInfo.vue')},
{path: 'tracking', component: () => import('@/pages/market/Tracking.vue')},
{path: 'scan', component: () => import('@/pages/market/Scan.vue')},
{path: 'acquisitions', component: () => import('@/pages/market/Acquisitions.vue')},
]},