src/blackrock/exchange-desk-model.ts

export type DeskFilters={minPrice:string;maxPrice:string;minQuantity:string;minValue:string;twoSided:boolean;unvisited:boolean};
export const emptyDeskFilters=():DeskFilters=>({minPrice:'',maxPrice:'',minQuantity:'',minValue:'',twoSided:false,unvisited:false});
const bound=(v:string)=>v.trim()===''?null:Number(v);
export function deskFilterError(f:DeskFilters){
 const values=[f.minPrice,f.maxPrice,f.minQuantity,f.minValue].map(bound);
 if(values.some(v=>v!==null&&(!Number.isFinite(v)||v<0)))return 'Use positive numbers or zero for numeric filters.';
 if(values[0]!==null&&values[1]!==null&&values[0]>values[1])return 'Minimum price must not exceed maximum price.';
 return null;
}
/** Filters refer to the selected side; absent evidence never satisfies a numeric bound. */
export function matchesDeskFilters(r:any,book:'buy'|'sell'|'recent',f:DeskFilters,visits:Record<string,number>){
 if(deskFilterError(f))return false;
 const [p,q,v]=book==='buy'?[r.bid,r.buyQuantity,r.buyValue]:book==='sell'?[r.ask,r.sellQuantity,r.sellValue]:[r.lastPrice,r.volume,r.gross];
 const passes=(value:unknown,raw:string,min=true)=>{const b=bound(raw);return b===null||typeof value==='number'&&Number.isFinite(value)&&(min?value>=b:value<=b);};
 return passes(p,f.minPrice)&&passes(p,f.maxPrice,false)&&passes(q,f.minQuantity)&&passes(v,f.minValue)&&(!f.twoSided||(typeof r.bid==='number'&&r.bid>0&&typeof r.ask==='number'&&r.ask>0&&r.buyQuantity>0&&r.sellQuantity>0))&&(!f.unvisited||!visits[r.id]);
}
export type DeskColumn={key:string;label:string;numeric?:boolean};
export function deskColumns(book:'buy'|'sell'|'recent'):DeskColumn[]{
 const numbers=book==='buy'?[['bid','Bid · GP'],['buyQuantity','Buy qty'],['buyValue','Buy value · GP'],['ask','Ask · GP'],['sellQuantity','Sell qty'],['sellValue','Sell value · GP']]:book==='sell'?[['ask','Ask · GP'],['sellQuantity','Sell qty'],['sellValue','Sell value · GP'],['bid','Bid · GP'],['buyQuantity','Buy qty'],['buyValue','Buy value · GP']]:[['lastPrice','Last price · GP'],['volume','Traded qty'],['gross','Trade value · GP'],['bid','Bid · GP'],['ask','Ask · GP']];
 return [{key:'name',label:'Item'},...numbers.map(([key,label])=>({key,label,numeric:true})),{key:'lastSellAt',label:'Last listed'},{key:'lastTradeAt',label:'Last trade'},{key:'visited',label:'Last visited'}];
}
/** Quoted CSV with spreadsheet formula neutralization; unknown numbers stay blank. */
export function exchangeCsv(rows:any[],columns:DeskColumn[],visits:Record<string,number>){
 const cell=(v:unknown)=>{let s=v==null?'':String(v);if(/^[\s]*[=+@-]/.test(s))s="'"+s;return '"'+s.replaceAll('"','""')+'"';};
 const value=(r:any,key:string)=>{const v=key==='visited'?visits[r.id]:r[key];return ['visited','lastTradeAt','lastSellAt'].includes(key)?typeof v==='number'&&Number.isFinite(v)?new Date(v).toISOString():null:v;};
 return [['Item ID',...columns.map(c=>c.label)],...rows.map(r=>[r.id,...columns.map(c=>value(r,c.key))])].map(row=>row.map(cell).join(',')).join('\r\n');
}