Skip to content

Commit 5f550d9

Browse files
authored
Merge pull request #210 from GeneralMagicio/addTokenPriceHistory
Add token price history
2 parents 950b475 + 5f0c781 commit 5f550d9

File tree

13 files changed

+750
-243
lines changed

13 files changed

+750
-243
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class CreateTokenPriceHistory1746613421845
4+
implements MigrationInterface
5+
{
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`
8+
CREATE TABLE "token_price_history" (
9+
"id" SERIAL NOT NULL,
10+
"token" text NOT NULL,
11+
"tokenAddress" text NOT NULL,
12+
"price" float NOT NULL,
13+
"priceUSD" float,
14+
"marketCap" float,
15+
"timestamp" TIMESTAMP NOT NULL DEFAULT now(),
16+
CONSTRAINT "PK_token_price_history" PRIMARY KEY ("id")
17+
);
18+
19+
CREATE UNIQUE INDEX "IDX_token_price_history_token_timestamp"
20+
ON "token_price_history" ("tokenAddress", "timestamp");
21+
`);
22+
}
23+
24+
public async down(queryRunner: QueryRunner): Promise<void> {
25+
await queryRunner.query(`
26+
DROP INDEX "IDX_token_price_history_token_timestamp";
27+
DROP TABLE "token_price_history";
28+
`);
29+
}
30+
}

package.json

Lines changed: 244 additions & 243 deletions
Large diffs are not rendered by default.

src/entities/entities.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { SwapTransaction } from './swapTransaction';
4040
import { QaccPointsHistory } from './qaccPointsHistory';
4141
import { UserRankMaterializedView } from './userRanksMaterialized';
4242
import { VestingData } from './vestingData';
43+
import { TokenPriceHistory } from './tokenPriceHistory';
4344

4445
export const getEntities = (): DataSourceOptions['entities'] => {
4546
return [
@@ -93,5 +94,6 @@ export const getEntities = (): DataSourceOptions['entities'] => {
9394
SwapTransaction,
9495
UserRankMaterializedView,
9596
VestingData,
97+
TokenPriceHistory,
9698
];
9799
};

src/entities/tokenPriceHistory.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Field, ID, ObjectType, Float } from 'type-graphql';
2+
import {
3+
PrimaryGeneratedColumn,
4+
Column,
5+
Entity,
6+
BaseEntity,
7+
CreateDateColumn,
8+
Index,
9+
} from 'typeorm';
10+
11+
@Entity()
12+
@ObjectType()
13+
@Index(['tokenAddress', 'timestamp'], { unique: true })
14+
export class TokenPriceHistory extends BaseEntity {
15+
@Field(_type => ID)
16+
@PrimaryGeneratedColumn()
17+
id: number;
18+
19+
@Field(_type => String)
20+
@Column()
21+
token: string;
22+
23+
@Field(_type => String)
24+
@Column()
25+
tokenAddress: string;
26+
27+
@Field(_type => Float)
28+
@Column('float')
29+
price: number;
30+
31+
@Field(_type => Float, { nullable: true })
32+
@Column('float', { nullable: true })
33+
priceUSD: number;
34+
35+
@Field(_type => Float, { nullable: true })
36+
@Column('float', { nullable: true })
37+
marketCap: number;
38+
39+
@Field()
40+
@CreateDateColumn()
41+
timestamp: Date;
42+
}

src/resolvers/resolvers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { OnboardingFormResolver } from './onboardingFormResolver';
1717
import { RoundsResolver } from './roundsResolver';
1818
import { QAccResolver } from './qAccResolver';
1919
import { QaccPointsHistoryResolver } from './qaccPointsHistoryResolver';
20+
import { TokenPriceResolver } from './tokenPriceResolver';
2021

2122
// eslint-disable-next-line @typescript-eslint/ban-types
2223
export const getResolvers = (): Function[] => {
@@ -44,5 +45,6 @@ export const getResolvers = (): Function[] => {
4445

4546
QAccResolver,
4647
QaccPointsHistoryResolver,
48+
TokenPriceResolver,
4749
];
4850
};
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { assert } from 'chai';
2+
import axios from 'axios';
3+
import {
4+
graphqlUrl,
5+
generateRandomEtheriumAddress,
6+
} from '../../test/testUtils';
7+
import { TokenPriceHistory } from '../entities/tokenPriceHistory';
8+
import { AppDataSource } from '../orm';
9+
import {
10+
getTokenPriceHistoryQuery,
11+
getTokenMarketCapChanges24hQuery,
12+
} from '../../test/graphqlQueries';
13+
14+
describe('TokenPriceResolver test cases', () => {
15+
describe('getTokenPriceHistory() test cases', getTokenPriceHistoryTestCases);
16+
describe(
17+
'getTokenMarketCapChanges24h() test cases',
18+
getTokenMarketCapChanges24hTestCases,
19+
);
20+
});
21+
22+
function getTokenPriceHistoryTestCases() {
23+
let tokenAddress: string;
24+
let repository;
25+
26+
beforeEach(async () => {
27+
// Get repository
28+
repository = AppDataSource.getDataSource().getRepository(TokenPriceHistory);
29+
30+
// Create test data
31+
tokenAddress = generateRandomEtheriumAddress();
32+
await Promise.all([
33+
repository.save({
34+
token: 'TEST',
35+
tokenAddress: tokenAddress.toLowerCase(),
36+
price: 1.5,
37+
priceUSD: 2.0,
38+
marketCap: 1000000,
39+
timestamp: new Date(Date.now() - 48 * 60 * 60 * 1000), // 48 hours ago
40+
}),
41+
repository.save({
42+
token: 'TEST',
43+
tokenAddress: tokenAddress.toLowerCase(),
44+
price: 1.6,
45+
priceUSD: 2.1,
46+
marketCap: 1100000,
47+
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000), // 24 hours ago
48+
}),
49+
repository.save({
50+
token: 'TEST',
51+
tokenAddress: tokenAddress.toLowerCase(),
52+
price: 1.7,
53+
priceUSD: 2.2,
54+
marketCap: 1200000,
55+
timestamp: new Date(), // now
56+
}),
57+
]);
58+
});
59+
60+
afterEach(async () => {
61+
// Clean up test data
62+
await repository.delete({
63+
tokenAddress: tokenAddress.toLowerCase(),
64+
});
65+
});
66+
67+
it('should return all price history entries for a token', async () => {
68+
const result = await axios.post(graphqlUrl, {
69+
query: getTokenPriceHistoryQuery,
70+
variables: {
71+
tokenAddress,
72+
},
73+
});
74+
75+
const entries = result.data.data.getTokenPriceHistory;
76+
assert.isArray(entries);
77+
assert.equal(entries.length, 3);
78+
assert.equal(entries[0].tokenAddress, tokenAddress.toLowerCase());
79+
assert.equal(entries[0].price, 1.7); // Most recent first
80+
assert.equal(entries[0].marketCap, 1200000);
81+
});
82+
83+
it('should return price history within a time range', async () => {
84+
const startTime = new Date(Date.now() - 36 * 60 * 60 * 1000); // 36 hours ago
85+
const endTime = new Date(Date.now() - 12 * 60 * 60 * 1000); // 12 hours ago
86+
87+
const result = await axios.post(graphqlUrl, {
88+
query: getTokenPriceHistoryQuery,
89+
variables: {
90+
tokenAddress,
91+
startTime,
92+
endTime,
93+
},
94+
});
95+
96+
const entries = result.data.data.getTokenPriceHistory;
97+
assert.isArray(entries);
98+
assert.equal(entries.length, 1); // Only the 24-hour old entry should be included
99+
assert.equal(entries[0].price, 1.6);
100+
assert.equal(entries[0].marketCap, 1100000);
101+
});
102+
}
103+
104+
function getTokenMarketCapChanges24hTestCases() {
105+
let tokenAddress: string;
106+
let repository;
107+
108+
beforeEach(async () => {
109+
// Get repository
110+
repository = AppDataSource.getDataSource().getRepository(TokenPriceHistory);
111+
112+
// Create test data
113+
tokenAddress = generateRandomEtheriumAddress();
114+
await Promise.all([
115+
repository.save({
116+
token: 'TEST',
117+
tokenAddress: tokenAddress.toLowerCase(),
118+
price: 1.5,
119+
priceUSD: 2.0,
120+
marketCap: 1000000,
121+
timestamp: new Date(Date.now() - 48 * 60 * 60 * 1000), // 48 hours ago
122+
}),
123+
repository.save({
124+
token: 'TEST',
125+
tokenAddress: tokenAddress.toLowerCase(),
126+
price: 1.6,
127+
priceUSD: 2.1,
128+
marketCap: 1100000,
129+
timestamp: new Date(Date.now() - 23 * 60 * 60 * 1000), // 23 hours ago
130+
}),
131+
repository.save({
132+
token: 'TEST',
133+
tokenAddress: tokenAddress.toLowerCase(),
134+
price: 1.7,
135+
priceUSD: 2.2,
136+
marketCap: 1200000,
137+
timestamp: new Date(), // now
138+
}),
139+
]);
140+
});
141+
142+
afterEach(async () => {
143+
// Clean up test data
144+
await repository.delete({
145+
tokenAddress: tokenAddress.toLowerCase(),
146+
});
147+
});
148+
149+
it('should return market cap changes in the last 24 hours', async () => {
150+
const result = await axios.post(graphqlUrl, {
151+
query: getTokenMarketCapChanges24hQuery,
152+
variables: {
153+
tokenAddress,
154+
},
155+
});
156+
157+
const entries = result.data.data.getTokenMarketCapChanges24h;
158+
assert.isArray(entries);
159+
assert.equal(entries.length, 2); // Only entries from last 24 hours
160+
assert.equal(entries[0].tokenAddress, tokenAddress.toLowerCase());
161+
assert.equal(entries[0].price, 1.7); // Most recent first
162+
assert.equal(entries[0].marketCap, 1200000);
163+
assert.equal(entries[1].price, 1.6);
164+
assert.equal(entries[1].marketCap, 1100000);
165+
});
166+
167+
it('should return empty array for non-existent token', async () => {
168+
const nonExistentAddress = generateRandomEtheriumAddress();
169+
const result = await axios.post(graphqlUrl, {
170+
query: getTokenMarketCapChanges24hQuery,
171+
variables: {
172+
tokenAddress: nonExistentAddress,
173+
},
174+
});
175+
176+
const entries = result.data.data.getTokenMarketCapChanges24h;
177+
assert.isArray(entries);
178+
assert.equal(entries.length, 0);
179+
});
180+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { Resolver, Query, Arg } from 'type-graphql';
2+
import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
3+
import { TokenPriceHistory } from '../entities/tokenPriceHistory';
4+
import { logger } from '../utils/logger';
5+
6+
@Resolver(_of => TokenPriceHistory)
7+
export class TokenPriceResolver {
8+
@Query(_returns => [TokenPriceHistory])
9+
async getTokenPriceHistory(
10+
@Arg('tokenAddress') tokenAddress: string,
11+
@Arg('startTime', { nullable: true })
12+
startTime?: Date,
13+
@Arg('endTime', { nullable: true })
14+
endTime?: Date,
15+
): Promise<TokenPriceHistory[]> {
16+
try {
17+
const where: any = {
18+
tokenAddress: tokenAddress.toLowerCase(),
19+
};
20+
21+
if (startTime && endTime) {
22+
where.timestamp = Between(startTime, endTime);
23+
} else if (startTime) {
24+
where.timestamp = MoreThanOrEqual(startTime);
25+
} else if (endTime) {
26+
where.timestamp = LessThanOrEqual(endTime);
27+
}
28+
29+
return await TokenPriceHistory.find({
30+
where,
31+
order: { timestamp: 'DESC' },
32+
});
33+
} catch (error) {
34+
logger.error('Error fetching token price history', {
35+
tokenAddress,
36+
error: error.message,
37+
});
38+
throw error;
39+
}
40+
}
41+
42+
@Query(_returns => [TokenPriceHistory])
43+
async getTokenMarketCapChanges24h(
44+
@Arg('tokenAddress') tokenAddress: string,
45+
): Promise<TokenPriceHistory[]> {
46+
try {
47+
const now = new Date();
48+
const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
49+
50+
const priceHistory = await TokenPriceHistory.find({
51+
where: {
52+
tokenAddress: tokenAddress.toLowerCase(),
53+
timestamp: Between(twentyFourHoursAgo, now),
54+
},
55+
order: { timestamp: 'DESC' },
56+
});
57+
58+
return priceHistory;
59+
} catch (error) {
60+
logger.error('Error fetching 24h market cap changes', {
61+
tokenAddress,
62+
error: error.message,
63+
});
64+
throw error;
65+
}
66+
}
67+
}

src/scalars/dateTime.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { GraphQLScalarType } from 'graphql';
2+
3+
export const DateTimeScalar = new GraphQLScalarType({
4+
name: 'DateTime',
5+
description: 'DateTime custom scalar type',
6+
serialize(value: unknown) {
7+
if (!(value instanceof Date)) {
8+
throw new Error('Expected value to be a Date');
9+
}
10+
return value.toISOString(); // Convert outgoing Date to ISO String for JSON
11+
},
12+
parseValue(value: unknown) {
13+
if (typeof value !== 'string') {
14+
throw new Error('DateTime must be a string');
15+
}
16+
return new Date(value); // Convert incoming ISO String to Date
17+
},
18+
parseLiteral(ast) {
19+
if (ast.kind === 'StringValue') {
20+
return new Date(ast.value); // Convert hard-coded AST string to Date
21+
}
22+
return null; // Invalid hard-coded value (not a string)
23+
},
24+
});

src/server/bootstrap.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import { ChainType } from '../types/network';
6767
import { runSyncDataWithInverter } from '../services/cronJobs/syncDataWithInverter';
6868
import { runSyncWithAnkrTransfers } from '../services/cronJobs/syncWithAnkrTransfers';
6969
import { runCheckPendingSwapsCronJob } from '../services/cronJobs/syncSwapTransactions';
70+
import { startTokenPriceCron } from '../services/cronJobs/tokenPriceCron';
7071

7172
Resource.validate = validate;
7273

@@ -164,6 +165,8 @@ export async function bootstrap() {
164165

165166
// runUpdateUserRanksCronJob();
166167

168+
startTokenPriceCron();
169+
167170
if (process.env.ENABLE_IMPORT_LOST_DONATIONS === 'true') {
168171
runSyncLostDonations();
169172
}

0 commit comments

Comments
 (0)