first commit
This commit is contained in:
commit
e28ff3afa4
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
stock/**/*
|
||||
.vscode/**/*
|
||||
__pycache__/**/*
|
||||
dist/**/*
|
||||
stock.db
|
20
LICENSE
Normal file
20
LICENSE
Normal file
@ -0,0 +1,20 @@
|
||||
Copyright (c) 2022 monoid
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
12
app.py
Normal file
12
app.py
Normal file
@ -0,0 +1,12 @@
|
||||
from distutils.log import debug
|
||||
import flask
|
||||
|
||||
app = flask.Flask(__name__)
|
||||
|
||||
@app.route("/<m>")
|
||||
def index(m:str):
|
||||
return flask.send_from_directory("dist", m)
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=12001, debug=True)
|
||||
|
196
db.py
Normal file
196
db.py
Normal file
@ -0,0 +1,196 @@
|
||||
import sqlite3
|
||||
import datetime
|
||||
import pandas as pd
|
||||
import krx
|
||||
import os
|
||||
import argparse
|
||||
import enum
|
||||
import json
|
||||
import tqdm
|
||||
|
||||
CREATE_STOCK_STATEMENT = """
|
||||
CREATE TABLE "STOCK" (
|
||||
"Code" TEXT,
|
||||
"Date" TEXT,
|
||||
"Close" INTEGER NOT NULL,
|
||||
"Diff" INTEGER NOT NULL,
|
||||
"Open" INTEGER NOT NULL,
|
||||
"High" INTEGER NOT NULL,
|
||||
"Low" INTEGER NOT NULL,
|
||||
"Volume" INTEGER NOT NULL,
|
||||
PRIMARY KEY("Date","Code")
|
||||
);"""
|
||||
|
||||
@enum.unique
|
||||
class STOCK_INDEX(enum.IntEnum):
|
||||
CODE = 0
|
||||
DATE = 1
|
||||
CLOSE = 2
|
||||
DIFF = 3
|
||||
OPEN = 4
|
||||
HIGH = 5
|
||||
LOW = 6
|
||||
VOLUME = 7
|
||||
|
||||
|
||||
CREATE_KRXCorp_STATEMENT = """
|
||||
CREATE TABLE "KRXCorp" (
|
||||
"Name" TEXT,
|
||||
"Code" TEXT,
|
||||
"Sector" TEXT,
|
||||
"Product" TEXT,
|
||||
"ListingDay" TEXT,
|
||||
"ClosingMonth" TEXT,
|
||||
"Representative" TEXT,
|
||||
"Homepage" TEXT,
|
||||
"AddressArea" TEXT,
|
||||
"LastUpdate" TEXT,
|
||||
PRIMARY KEY("Code")
|
||||
);
|
||||
"""
|
||||
CREATE_UNIQUE_Stock_INDEX_Statement = """
|
||||
CREATE UNIQUE INDEX "STOCK_INDEX" ON "STOCK" (
|
||||
"Code",
|
||||
"Date"
|
||||
)
|
||||
"""
|
||||
def create_table(nday:int = 90):
|
||||
"""
|
||||
Create table for stock data
|
||||
|
||||
:param nday: determine lastest crolling day.
|
||||
"""
|
||||
print("initialize table...")
|
||||
with sqlite3.connect("stock.db") as db:
|
||||
db.execute(CREATE_STOCK_STATEMENT)
|
||||
db.execute(CREATE_KRXCorp_STATEMENT)
|
||||
db.execute(CREATE_UNIQUE_Stock_INDEX_Statement)
|
||||
code_df: pd.DataFrame = krx.load_from_krx_server()
|
||||
code_df.to_csv("krx.csv")
|
||||
print("compelete to download from krx")
|
||||
code_df = code_df.rename(columns={'회사명': 'name', '종목코드': 'code'})
|
||||
code_df_size = len(code_df)
|
||||
last_updated = (datetime.date.today() - datetime.timedelta(nday)).isoformat()
|
||||
for i,row in code_df.iterrows():
|
||||
code = row["code"]
|
||||
print(f"{code_df_size}/{i+1} : code { code }",end="\r")
|
||||
db.execute("""
|
||||
INSERT INTO "KRXCorp" (Name,Code,Sector,Product,ListingDay,ClosingMonth,Representative,Homepage,AddressArea,LastUpdate) VALUES (
|
||||
?,?,?,?,?,?,?,?,?,?)
|
||||
""",(row["name"],row["code"],row["업종"],row["주요제품"],row["상장일"],row["결산월"],row["대표자명"],row["홈페이지"],row["지역"]
|
||||
, last_updated))
|
||||
print("\nComplete!")
|
||||
db.commit()
|
||||
|
||||
def update_krx(nday:int = 90):
|
||||
print("update krx...")
|
||||
with sqlite3.connect("stock.db") as db:
|
||||
if os.path.exists("krx.csv"):
|
||||
code_df = pd.read_csv("krx.csv", index_col=0, dtype=str)
|
||||
else:
|
||||
code_df: pd.DataFrame = krx.load_from_krx_server()
|
||||
code_df.to_csv("krx.csv")
|
||||
print("compelete to download from krx")
|
||||
code_df = code_df.rename(columns={'회사명': 'name', '종목코드': 'code'})
|
||||
pbar = tqdm.tqdm(code_df.iterrows(), total=len(code_df))
|
||||
for _,row in pbar:
|
||||
code:str = row["code"]
|
||||
name:str = row["name"]
|
||||
pbar.set_description(f"{ code }")
|
||||
q = db.execute("SELECT COUNT(*) FROM KRXCorp Where Code = ?",[code])
|
||||
a = q.fetchone()[0]
|
||||
lastUpdate = (datetime.date.today() - datetime.timedelta(nday)).isoformat()
|
||||
if a > 0:
|
||||
db.execute("""
|
||||
UPDATE "KRXCorp" Set Name = ?,
|
||||
Sector = ?,
|
||||
Product = ?,
|
||||
ListingDay = ?,
|
||||
ClosingMonth = ?,
|
||||
Representative = ?,
|
||||
Homepage = ?,
|
||||
AddressArea = ?,
|
||||
WHERE Code = ?;
|
||||
""",(row["name"],row["업종"],row["주요제품"],row["상장일"],row["결산월"],row["대표자명"],row["홈페이지"],row["지역"],code
|
||||
))
|
||||
else:
|
||||
db.execute("""
|
||||
INSERT INTO "KRXCorp" (Name,Code,Sector,Product,ListingDay,ClosingMonth,Representative,Homepage,AddressArea,LastUpdate) VALUES (
|
||||
?,?,?,?,?,?,?,?,?,?)
|
||||
""",(row["name"],row["code"],row["업종"],row["주요제품"],row["상장일"],row["결산월"],row["대표자명"],row["홈페이지"],row["지역"],lastUpdate
|
||||
))
|
||||
print("\nComplete!")
|
||||
db.commit()
|
||||
|
||||
def get_data_from_krx(db,stock_code):
|
||||
cursor = db.execute("""SELECT * FROM KRXCorp WHERE code = ?""", [stock_code])
|
||||
return cursor.fetchone()
|
||||
|
||||
|
||||
class KRXCorp:
|
||||
__slots__ = ("Name","Code","Sector","Product","ListingDay",
|
||||
"ClosingMonth","Representative","Homepage","AddressArea","LastUpdate")
|
||||
|
||||
def __init__(self,**kwargs):
|
||||
super().__init__()
|
||||
self.Name = kwargs["Name"]
|
||||
self.Code = kwargs["Code"]
|
||||
self.Sector = kwargs["Sector"]
|
||||
self.Product = kwargs["Product"]
|
||||
self.ListingDay = kwargs["ListingDay"]
|
||||
self.ClosingMonth = kwargs["ClosingMonth"]
|
||||
self.Representative = kwargs["Representative"]
|
||||
self.Homepage = kwargs["Homepage"]
|
||||
self.AddressArea = kwargs["AddressArea"]
|
||||
self.LastUpdate = kwargs["LastUpdate"]
|
||||
@classmethod
|
||||
def from_db(cls,arr):
|
||||
return cls(Name=arr[0], Code = arr[1],Sector =arr[2],Product =arr[3],
|
||||
ListingDay =arr[4],ClosingMonth =arr[5],Representative= arr[6],
|
||||
Homepage = arr[7],AddressArea = arr[8],LastUpdate = arr[9])
|
||||
|
||||
def __repr__(self):
|
||||
return f"{{{self.Name}: {self.Code}}}"
|
||||
|
||||
def toDict(self):
|
||||
return {
|
||||
"Name": self.Name,
|
||||
"Code": self.Code,
|
||||
"Sector": self.Sector,
|
||||
"Product": self.Product,
|
||||
"ListingDay": self.ListingDay,
|
||||
"ClosingMonth": self.ClosingMonth,
|
||||
"Representative": self.Representative,
|
||||
"Homepage": self.Homepage,
|
||||
"AddressArea": self.AddressArea,
|
||||
"LastUpdate": self.LastUpdate
|
||||
}
|
||||
|
||||
def GetAllKRXCorp(db):
|
||||
return [KRXCorp.from_db(c) for c in db.execute("""SELECT * From KRXCorp""")]
|
||||
|
||||
def GetAllStockCode(db):
|
||||
return [c[0] for c in db.execute("""SELECT Code From KRXCorp""")]
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser(description="Stock DataBase")
|
||||
parser.add_argument("--create",action="store_true",help="create table")
|
||||
parser.add_argument("--update",action="store_true",help="update table")
|
||||
parser.add_argument("--getAll",action="store_true",help="get data from krx")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.create:
|
||||
if os.path.exists("stock.db"):
|
||||
print("db file already exists!")
|
||||
else:
|
||||
create_table()
|
||||
|
||||
if args.update:
|
||||
update_krx()
|
||||
|
||||
if args.getAll:
|
||||
with sqlite3.connect("stock.db") as db:
|
||||
print(GetAllKRXCorp(db))
|
227
gen.py
Normal file
227
gen.py
Normal file
@ -0,0 +1,227 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from typing import Dict, List
|
||||
from render import *
|
||||
import db as database
|
||||
from jinja2 import Environment, PackageLoader, select_autoescape
|
||||
import pandas as pd
|
||||
import tqdm
|
||||
|
||||
class DataStore:
|
||||
def __init__(self) -> None:
|
||||
self.db = sqlite3.connect("stock.db")
|
||||
self.pricesCache: Dict[str,] = {}
|
||||
|
||||
def getAllKRXCorp(self) -> List[database.KRXCorp]:
|
||||
return database.GetAllKRXCorp(self.db)
|
||||
|
||||
def getStockPrice(self,code,length) -> pd.DataFrame:
|
||||
if code in self.pricesCache and len(self.pricesCache[code]) >= length:
|
||||
return self.pricesCache[code]
|
||||
else:
|
||||
s = GetStockPriceFrom(self.db,code,length)
|
||||
s = pd.DataFrame(s,
|
||||
columns=[s for s in database.STOCK_INDEX.__members__.keys()])
|
||||
s.set_index("DATE", inplace=True)
|
||||
self.pricesCache[code] = s
|
||||
return self.pricesCache[code]
|
||||
|
||||
def clearCache(self) -> None:
|
||||
self.pricesCache = {}
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.db.close()
|
||||
|
||||
|
||||
class OutputCollectorElement:
|
||||
def __init__(self, name: str, description: str) -> None:
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.corpListByDate:Dict[str,database.KRXCorp] = {}
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"OutputCollectorElement:{self.name}"
|
||||
|
||||
def addCorp(self, date, corp):
|
||||
self.corpListByDate.setdefault(date, []).append(corp)
|
||||
|
||||
def toDict(self) -> Dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"corpListByDate": {k:[d.toDict() for d in v]
|
||||
for k,v in self.corpListByDate.items()}
|
||||
}
|
||||
|
||||
class OutputCollector:
|
||||
def __init__(self) -> None:
|
||||
self.data: Dict[str,OutputCollectorElement] = {}
|
||||
|
||||
def addResult(self, key, help = ""):
|
||||
"""
|
||||
add output category to collect
|
||||
"""
|
||||
self.data[key] = OutputCollectorElement(key, help)
|
||||
|
||||
def collect(self, key, corp, date):
|
||||
self.data[key].addCorp(date, corp)
|
||||
|
||||
def isVolumeNTimes(stock: pd.DataFrame, mul: float, nday:int, order=1) -> bool:
|
||||
return stock.iloc[nday]['VOLUME'] > stock.iloc[nday+order]['VOLUME'] * mul
|
||||
|
||||
def isVolumeMulPriceGreaterThan(stock: pd.DataFrame, threshold: int, nday: int) -> bool:
|
||||
return stock.iloc[nday]['VOLUME'] * stock.iloc[nday]['CLOSE'] > threshold
|
||||
|
||||
def isMACDCrossSignal(signal: pd.Series, macd: pd.Series, nday: int, order=1) -> bool:
|
||||
return (signal.iloc[nday] < macd.iloc[nday] and
|
||||
signal.iloc[nday+order] > macd.iloc[nday+order])
|
||||
|
||||
def isRelativeDiffLessThan(a:pd.Series,b:pd.Series, threshold: float,nday:int) -> bool:
|
||||
return (a.iloc[nday] - b.iloc[nday]) / b.iloc[nday] < threshold
|
||||
|
||||
def isDiffGreaterThan(a:pd.Series,b:pd.Series, nday:int) -> bool:
|
||||
"""a is bigger than b"""
|
||||
return (a.iloc[nday] > b.iloc[nday])
|
||||
|
||||
def prepareCollector(collector: OutputCollector) -> None:
|
||||
collector.addResult("cross 2", """\
|
||||
5일선과 20일선이 서로 만나는 시점 즉 상대 오차가 1% 이하이고
|
||||
5일선과 60일선이 서로 만나는 시점을 찾습니다.
|
||||
""")
|
||||
collector.addResult("cross 3", """\
|
||||
cross 2의 조건에서 더해서 거래량이 이전 날짜보다 3배 증가하고
|
||||
100000 이상인 시점을 찾습니다.
|
||||
""")
|
||||
collector.addResult("cross 4", """\
|
||||
20일선과 60일선이 서로 만나는 시점 즉 상대 오차가 1% 이하이고
|
||||
거래량이 1000000 이상인 시점을 찾습니다.
|
||||
""")
|
||||
collector.addResult("d20d5", """\
|
||||
5일선이 20선보다 큰 시점을 찾습니다.
|
||||
""")
|
||||
collector.addResult("d20d5VolumeX5", """\
|
||||
d20d5의 조건에서 더해서 거래량이 이전 날짜보다 5배 증가한 시점을 찾습니다.
|
||||
""")
|
||||
collector.addResult("DiffDistance", """\
|
||||
5일선과 20일선이 서로 만나는 시점 즉 상대 오차가 3% 이하이고
|
||||
5일선과 60일선이 서로 만나고 거래량이 이전 날짜보다 3배 증가한
|
||||
시점을 찾습니다.
|
||||
""")
|
||||
collector.addResult("volume", """\
|
||||
거래량이 이전 날짜보다 3배 증가한 시점을 찾습니다.
|
||||
""")
|
||||
collector.addResult("volume5", """\
|
||||
거래량과 가격의 곱이 50,000,000,000 이상인 시점을 찾습니다.
|
||||
""")
|
||||
collector.addResult("volumeX5", """\
|
||||
거래량이 이전 날짜보다 5배 증가한 시점을 찾습니다.
|
||||
""")
|
||||
collector.addResult("macd", """\
|
||||
signal과 macd가 서로 교차한 시점을 찾습니다. 즉 signal이 아래로 떨어지고
|
||||
macd가 위로 올라가는 시점을 찾습니다.
|
||||
""")
|
||||
|
||||
def collect(data: DataStore, collector: OutputCollector, corp: database.KRXCorp
|
||||
, nday: int) -> None:
|
||||
stock = data.getStockPrice(corp.Code,70)
|
||||
if len(stock) < 70:
|
||||
return
|
||||
|
||||
d5 = stock["CLOSE"].loc[::-1].rolling(window=5
|
||||
).mean().dropna().loc[::-1]
|
||||
d20 = stock["CLOSE"].loc[::-1].rolling(window=20
|
||||
).mean().dropna().loc[::-1]
|
||||
d60 = stock["CLOSE"].loc[::-1].rolling(window=60
|
||||
).mean().dropna().loc[::-1]
|
||||
|
||||
if (isRelativeDiffLessThan(d5, d20, 0.01, nday) and
|
||||
isRelativeDiffLessThan(d5, d60, 0.01, nday)):
|
||||
collector.collect("cross 2", corp, stock.index[nday])
|
||||
if (isVolumeNTimes(stock, 3, 0) and
|
||||
isVolumeMulPriceGreaterThan(stock, 100000, nday)):
|
||||
collector.collect("cross 3", corp, stock.index[nday])
|
||||
|
||||
if (isRelativeDiffLessThan(d20, d60, 0.01, nday) and
|
||||
isVolumeMulPriceGreaterThan(stock, 1000000, nday)):
|
||||
collector.collect("cross 4", corp, stock.index[nday])
|
||||
|
||||
if (isDiffGreaterThan(d5, d20, nday)):
|
||||
collector.collect("d20d5", corp, stock.index[nday])
|
||||
if (isVolumeNTimes(stock, 5, nday)):
|
||||
collector.collect("d20d5VolumeX5", corp, stock.index[nday])
|
||||
|
||||
if (isRelativeDiffLessThan(d5, d20, 0.03, nday) and
|
||||
isRelativeDiffLessThan(d5, d60, 0.03, nday) and
|
||||
isVolumeNTimes(stock, 3, nday)):
|
||||
collector.collect("DiffDistance", corp, stock.index[nday])
|
||||
|
||||
if (isVolumeNTimes(stock, 3, nday)):
|
||||
collector.collect("volume", corp, stock.index[nday])
|
||||
|
||||
if (isVolumeMulPriceGreaterThan(stock, 50000000, nday)):
|
||||
collector.collect("volume5", corp, stock.index[nday])
|
||||
|
||||
if (isVolumeNTimes(stock, 5, nday)):
|
||||
collector.collect("volumeX5", corp, stock.index[nday])
|
||||
|
||||
ewm12 = stock["CLOSE"].loc[::-1].ewm(span=12).mean().loc[::-1]
|
||||
ewm26 = stock["CLOSE"].loc[::-1].ewm(span=26).mean().loc[::-1]
|
||||
macd = (ewm12 - ewm26)
|
||||
signal = macd.ewm(span=9).mean()
|
||||
|
||||
if (isMACDCrossSignal(macd, signal, nday)):
|
||||
collector.collect("macd", corp, stock.index[nday])
|
||||
|
||||
parser = argparse.ArgumentParser(description="주식 검색 정보를 출력합니다.")
|
||||
parser.add_argument("--format", "-f", choices=["json", "html"], default="html",
|
||||
help="출력 포맷을 지정합니다. 기본값은 html입니다.")
|
||||
parser.add_argument("--dir", "-d", default=".", help="출력할 폴더를 지정합니다.")
|
||||
parser.add_argument("--corp", "-c", help="주식 코드를 지정합니다. 지정하지 않으면 모든 주식을 검색합니다.")
|
||||
parser.add_argument("--printStdout", action="store_true", help="출력한 결과를 표준 출력으로 출력합니다.")
|
||||
parser.add_argument("--version", "-v", action="version", version="%(prog)s 1.0")
|
||||
parser.add_argument("--verbose", "-V", action="store_true", help="출력할 내용을 자세히 표시합니다.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parser.parse_args()
|
||||
dataStore = DataStore()
|
||||
krx_corps = dataStore.getAllKRXCorp()
|
||||
if args.corp:
|
||||
krx_corps = [corp for corp in krx_corps if corp.Code == args.corp]
|
||||
|
||||
env = Environment(
|
||||
loader=PackageLoader('render', 'templates'),
|
||||
autoescape=select_autoescape(['html', 'xml'])
|
||||
)
|
||||
collector = OutputCollector()
|
||||
prepareCollector(collector)
|
||||
|
||||
for corp in tqdm.tqdm(krx_corps):
|
||||
for nday in range(0, 5):
|
||||
collect(dataStore, collector, corp, nday)
|
||||
dataStore.clearCache()
|
||||
|
||||
for k,v in collector.data.items():
|
||||
if args.format == "json":
|
||||
data = json.dumps(v.toDict(), indent=4, ensure_ascii=False)
|
||||
if args.printStdout:
|
||||
print(k)
|
||||
print(data)
|
||||
else:
|
||||
with open(os.path.join(args.dir, k + ".json", encoding="UTF-8"), "w") as f:
|
||||
f.write(data)
|
||||
else:
|
||||
template = env.get_template("Lists.html")
|
||||
|
||||
days = v.corpListByDate.keys()
|
||||
days = list(days)
|
||||
days.sort(reverse=True)
|
||||
days = days[:5]
|
||||
|
||||
html = template.render(collected=v, title=k, days=days)
|
||||
if args.printStdout:
|
||||
print(html)
|
||||
else:
|
||||
with open(os.path.join(args.dir, k + ".html"), "w", encoding="UTF-8") as f:
|
||||
f.write(html)
|
18
krx.py
Normal file
18
krx.py
Normal file
@ -0,0 +1,18 @@
|
||||
import pandas as pd
|
||||
|
||||
def load_from_krx_server():
|
||||
code_df = pd.read_html('http://kind.krx.co.kr/corpgeneral/corpList.do?method=download&searchType=13', header=0)[0]
|
||||
code_df.종목코드 = code_df.종목코드.map('{:06d}'.format)
|
||||
return code_df
|
||||
|
||||
def load_stock_codes():
|
||||
code_df = pd.read_csv('krx.csv',index_col=0)
|
||||
|
||||
code_df.종목코드 = code_df.종목코드.map('{:06d}'.format)
|
||||
code_df = code_df[['회사명', '종목코드']]
|
||||
code_df = code_df.rename(columns={'회사명': 'name', '종목코드': 'code'})
|
||||
|
||||
stock_codes = code_df["code"]
|
||||
return stock_codes
|
||||
|
||||
|
54
print_json.py
Normal file
54
print_json.py
Normal file
@ -0,0 +1,54 @@
|
||||
import sqlite3
|
||||
from render import *
|
||||
import db as database
|
||||
import json
|
||||
import argparse
|
||||
import time
|
||||
import datetime
|
||||
|
||||
parser = argparse.ArgumentParser(description="print corp data to json")
|
||||
|
||||
parser.add_argument("--krx", action="store_true" , default=False,
|
||||
help="print krx corp data")
|
||||
|
||||
def printKRXCorpJson(corp):
|
||||
print("{", end="")
|
||||
i = 0
|
||||
for attr in corp.__slots__:
|
||||
if i > 0:
|
||||
print(",",end="")
|
||||
print("\"",attr,"\":\"",corp.__getattribute__(attr),"\"",sep="",end="")
|
||||
i += 1
|
||||
print("}",end="")
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parser.parse_args()
|
||||
db = sqlite3.connect("stock.db")
|
||||
if args.krx:
|
||||
print("[")
|
||||
corp_list = database.GetAllKRXCorp(db)
|
||||
j = 0
|
||||
for corp in corp_list:
|
||||
if j > 0:
|
||||
print(",")
|
||||
printKRXCorpJson(corp)
|
||||
j += 1
|
||||
#print(json.dumps(corp_list,ensure_ascii=False,indent=2))
|
||||
print("]")
|
||||
else:
|
||||
stock_list = GetAllStockPrice(db,60)
|
||||
i = 0
|
||||
print("[")
|
||||
for stock in stock_list:
|
||||
if i > 0:
|
||||
print(",")
|
||||
s = [*map(str,stock)][2:]
|
||||
code = f'"{stock[0]}"'
|
||||
date = str(datetime.datetime.fromisoformat(stock[1]).timestamp())
|
||||
print("[",code,",",date,",".join(s),"]",sep="",end="")
|
||||
i += 1
|
||||
print("]")
|
||||
db.close()
|
||||
|
||||
|
||||
|
97
render.py
Normal file
97
render.py
Normal file
@ -0,0 +1,97 @@
|
||||
import re
|
||||
import datetime
|
||||
import sqlite3
|
||||
|
||||
import csv
|
||||
import sqlite3
|
||||
import datetime
|
||||
from db import GetAllStockCode
|
||||
|
||||
def GetMovingAverageAt(db,code: str,day = 5,date = datetime.date.today()):
|
||||
if isinstance(day,list):
|
||||
listday = day
|
||||
day = max(day)
|
||||
else:
|
||||
listday = [day]
|
||||
last = date - datetime.timedelta(days=day)
|
||||
l = [row for row in db.execute("SELECT Date,Close FROM STOCK WHERE Code = ? AND Date <= ? ORDER BY Date DESC LIMIT ?",
|
||||
[code,last.isoformat(),day])]
|
||||
return [sum(map(lambda x: x[1],l[0:ad]))/ad for ad in listday]
|
||||
|
||||
def GetStockPriceFrom(db,code: str,n,date = datetime.date.today()):
|
||||
"""
|
||||
return (code,date,close,diff,open,high,low,volume)[]
|
||||
"""
|
||||
last = date - datetime.timedelta(days=n)
|
||||
return [row for row in db.execute(f"SELECT * FROM STOCK WHERE Code = ? AND Date <= ? ORDER BY Date DESC LIMIT ?",
|
||||
[code,date.isoformat(),n])]
|
||||
|
||||
def GetAllStockPrice(db,n,date = datetime.date.today()):
|
||||
"""
|
||||
return cursor
|
||||
"""
|
||||
last = date - datetime.timedelta(days=n)
|
||||
return db.execute(f"SELECT * FROM STOCK WHERE Date > ? ORDER BY Date DESC",
|
||||
[last.isoformat()])
|
||||
|
||||
#lastest is front
|
||||
def makeMovingAveragePoint(n,arr,reversed = False):
|
||||
if len(arr) < n:
|
||||
raise IndexError
|
||||
if not reversed:
|
||||
start = sum(arr[:n])
|
||||
ret = [start/n]
|
||||
for i in range(0,len(arr)-n):
|
||||
nex = start-arr[i]+arr[i+n]
|
||||
ret.append(nex/n)
|
||||
start = nex
|
||||
else:
|
||||
start = sum(arr[-n:])
|
||||
ret = [start/n]
|
||||
for i in range(0,len(arr)-n):
|
||||
nex = start-arr[-i-1]+arr[-i-1-n]
|
||||
ret.append(nex/n)
|
||||
start = nex
|
||||
return ret
|
||||
|
||||
def detectTF(d5,d20)-> bool:
|
||||
series = [*map(lambda x: x[0] > x[1],zip(d5,d20))]
|
||||
return series[0] and (not series[1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("start")
|
||||
db = sqlite3.connect("stock.db")
|
||||
|
||||
|
||||
|
||||
#stock_codes = GetAllStockCode(db)
|
||||
#
|
||||
#for stock_code in stock_codes:
|
||||
# arr = GetStockPriceFrom(db,stock_code,25)
|
||||
# if len(arr) < 25:
|
||||
# print(f"stock_code {stock_code} : lack of data")
|
||||
# continue
|
||||
# #print([*map(lambda x: x[2],arr)])
|
||||
# d5 = makeMovingAveragePoint(5,[*map(lambda x: x[2],arr)])
|
||||
# d20 = makeMovingAveragePoint(20,[*map(lambda x: x[2],arr)])
|
||||
# higher = detectTF(d5[:5],d20[:5])
|
||||
# if higher:
|
||||
# print(f"stock_code {stock_code} : {higher} {d5} {d20}")
|
||||
|
||||
#print(GetMovingAverageAt(db,"155660",day=[5,20]))
|
||||
|
||||
|
||||
|
||||
#arr = GetStockPriceFrom(db,"155660",25)
|
||||
#arr.sort(key=lambda x:x[2])
|
||||
#print([*map(lambda x: x[2],arr)])
|
||||
#print(makeMovingAveragePoint(2,[*map(lambda x: x[2],arr)]))
|
||||
#print(makeMovingAveragePoint(2,[*map(lambda x: x[2],arr)],reversed=True))
|
||||
#d5 = makeMovingAveragePoint(5,[*map(lambda x: x[2],arr)])
|
||||
#d20 = makeMovingAveragePoint(20,[*map(lambda x: x[2],arr)])
|
||||
#print(d5)
|
||||
#print(d20)
|
||||
#print(detectTF(d5,d20))
|
||||
#print(GetAllStockCode(db))
|
||||
db.close()
|
BIN
requirements.txt
Normal file
BIN
requirements.txt
Normal file
Binary file not shown.
9
static/index.html
Normal file
9
static/index.html
Normal file
@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Hello</h1>
|
||||
</body>
|
||||
</html>
|
91
stock_price.py
Normal file
91
stock_price.py
Normal file
@ -0,0 +1,91 @@
|
||||
import pandas as pd
|
||||
import bs4
|
||||
import requests
|
||||
import re
|
||||
import multiprocessing as mp
|
||||
import sqlite3
|
||||
import datetime
|
||||
|
||||
def get_naver_finance_price(code,page=1):
|
||||
#url = (f'https://finance.naver.com/item/sise_day.nhn?code={code}&page={page}')
|
||||
url = 'https://finance.naver.com/item/sise_day.nhn'
|
||||
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36"}
|
||||
# print(url)
|
||||
html = requests.get(url,params={'code':code,'page':page},headers=headers)
|
||||
if html.status_code != 200:
|
||||
raise UserWarning(html.status_code)
|
||||
return html.text
|
||||
|
||||
stock_h = ['날짜','종가','전일비','시가','고가','저가','거래량']
|
||||
def get_data(soup,date):
|
||||
nums = soup.select(".tah")
|
||||
i = 0
|
||||
ret=[pd.DataFrame(columns=stock_h)]
|
||||
nums = [*map(lambda x:x.text.replace(',','').strip(),nums)]
|
||||
while True:
|
||||
m = nums[i:(i+7)]
|
||||
if not m:
|
||||
break
|
||||
#for ISO 8601
|
||||
m[0] = m[0].replace(".","-")
|
||||
#date
|
||||
if m[0] <= date:
|
||||
return pd.concat(ret,ignore_index=True),True
|
||||
|
||||
ret.append(pd.DataFrame([m],columns=stock_h))
|
||||
i += 7
|
||||
return pd.concat(ret,ignore_index=True),False
|
||||
def get_last_page(soup):
|
||||
a = soup.select_one('.pgRR a')
|
||||
if a is None:
|
||||
index_list = soup.select('td a')
|
||||
return len(index_list)
|
||||
href = a.attrs['href']
|
||||
p = re.compile(r"page=(\d*)")
|
||||
g = p.search(href)
|
||||
return g.groups()[0]
|
||||
|
||||
def croll_naver_page(code,page,date):
|
||||
html_text = get_naver_finance_price(code,page)
|
||||
soup = bs4.BeautifulSoup(html_text,'html.parser')
|
||||
return get_data(soup,date)
|
||||
|
||||
def croll_naver_page_all(code,date) -> pd.DataFrame:
|
||||
html_text = get_naver_finance_price(code)
|
||||
#print(html_text)
|
||||
s = bs4.BeautifulSoup(html_text,'html.parser')
|
||||
last = int(get_last_page(s))
|
||||
r = [(code,i) for i in range(1,last+1)]
|
||||
retdata = []
|
||||
for c,pagenum in r:
|
||||
d,is_end = croll_naver_page(c,pagenum,date)
|
||||
if is_end:
|
||||
retdata.append(d)
|
||||
break
|
||||
retdata.append(d)
|
||||
if len(retdata) == 0:
|
||||
return []
|
||||
return pd.concat(retdata,ignore_index=True)
|
||||
#with mp.Pool(CPU_COUNT) as pl:
|
||||
# dl = pl.starmap(croll_naver_page,r)
|
||||
# return pd.concat(dl,ignore_index=True)
|
||||
|
||||
def toSqlPos(x,code):
|
||||
return (code,x["날짜"],x["종가"],x["전일비"],x["시가"],x["고가"],x["저가"],x["거래량"])
|
||||
|
||||
if __name__ == '__main__':
|
||||
db = sqlite3.connect("stock.db")
|
||||
today = datetime.date.today()
|
||||
|
||||
krx_stock_rows = [(i,code,last_update) for i,(code,last_update) in enumerate(db.execute("""SELECT Code,LastUpdate From KRXCorp"""))]
|
||||
total = len(krx_stock_rows)
|
||||
for i,code,last_update in krx_stock_rows:
|
||||
print(f"{total}/{i}: code {code} : {last_update}")
|
||||
if last_update == today.isoformat():
|
||||
continue
|
||||
d = croll_naver_page_all(code,last_update)
|
||||
cursor = db.cursor()
|
||||
if len(d)> 0:
|
||||
cursor.executemany("INSERT INTO STOCK (Code,Date,Close,Diff,Open,High,Low,Volume) VALUES (?,?,?,?,?,?,?,?)",[toSqlPos(x,code) for i,x in d.iterrows() ])
|
||||
cursor.execute("""UPDATE KRXCorp Set LastUpdate = ? WHERE Code = ?""",(today.isoformat(),code))
|
||||
db.commit()
|
59
templates/Lists.html
Normal file
59
templates/Lists.html
Normal file
@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Stock</title>
|
||||
<style>
|
||||
body{
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(to right, #2b2b2b, #3d1844);
|
||||
color: #fff;
|
||||
}
|
||||
.table_item:nth-child(2n){
|
||||
background: #a7a7a7;
|
||||
}
|
||||
.table_item:nth-child(2n+1){
|
||||
background: #fff;
|
||||
}
|
||||
.table_item:hover{
|
||||
background: #8d8d8d;
|
||||
}
|
||||
.container{
|
||||
display: grid;
|
||||
grid-template-rows: 24px auto;
|
||||
background: #f0f0f0;
|
||||
color: black;
|
||||
box-shadow: 0px 0px 5px 0px white;
|
||||
text-decoration: none;
|
||||
grid-template-columns: repeat({{ 5 }}, 1fr);
|
||||
}
|
||||
.container a:link, a:visited{
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.data_header{
|
||||
border-bottom: 1px solid #a7a7a7;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div style="margin: auto; max-width: 750px;">
|
||||
<h1>{{title}} Stock List</h1>
|
||||
<section class="description">
|
||||
{{collected.description}}
|
||||
</section>
|
||||
<section class="container">
|
||||
{% for day in days|reverse %}
|
||||
<div class="data_header">{{ day }}</div>
|
||||
{% endfor %}
|
||||
{% for day in days|reverse %}
|
||||
{% set corplist = collected.corpListByDate[day] %}
|
||||
<div>{% for item in corplist %}
|
||||
<div class="table_item"><a href="https://stockplus.com/m/stocks/KOREA-A{{ item.Code }}">{{ item.Name }}({{item.Code}})</a></div>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
328
test.ipynb
Normal file
328
test.ipynb
Normal file
@ -0,0 +1,328 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import sqlite3\n",
|
||||
"from typing import Dict\n",
|
||||
"from render import * \n",
|
||||
"import db as database\n",
|
||||
"from jinja2 import Environment, PackageLoader, select_autoescape\n",
|
||||
"import pandas as pd"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 8,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import importlib"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 14,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"<module 'db' from 'c:\\\\Users\\\\Monoid\\\\Desktop\\\\stock\\\\db.py'>"
|
||||
]
|
||||
},
|
||||
"execution_count": 14,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"importlib.reload(database)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"db = sqlite3.connect(\"stock.db\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 16,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"krx = database.GetAllKRXCorp(db)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 20,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"krxDf = pd.DataFrame([corp.toDict() for corp in krx])"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 22,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"data = GetStockPriceFrom(db,\"155660\", 61)\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 32,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"s = pd.DataFrame(data, columns=[s for s in database.STOCK_INDEX.__members__.keys()])"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 56,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"s.set_index(\"DATE\", inplace=True)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 91,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"stock = s"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 92,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"d5 = stock[\"CLOSE\"].loc[::-1].rolling(window=5\n",
|
||||
" ).mean().dropna().loc[::-1]\n",
|
||||
"d20 = stock[\"CLOSE\"].loc[::-1].rolling(window=20\n",
|
||||
" ).mean().dropna().loc[::-1]\n",
|
||||
"d60 = stock[\"CLOSE\"].loc[::-1].rolling(window=60\n",
|
||||
" ).mean().dropna().loc[::-1]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 100,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"ewm12 = stock[\"CLOSE\"].loc[::-1].ewm(span=12).mean().loc[::-1]\n",
|
||||
"ewm26 = stock[\"CLOSE\"].loc[::-1].ewm(span=26).mean().loc[::-1]\n",
|
||||
"macd = (ewm12 - ewm26)\n",
|
||||
"signal = macd.ewm(span=9).mean()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 101,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"DATE\n",
|
||||
"2022-05-20 148.895069\n",
|
||||
"2022-05-19 152.584580\n",
|
||||
"2022-05-18 122.762721\n",
|
||||
"2022-05-17 97.031260\n",
|
||||
"2022-05-16 50.671176\n",
|
||||
" ... \n",
|
||||
"2022-02-28 7.956286\n",
|
||||
"2022-02-25 1.291958\n",
|
||||
"2022-02-24 -0.770309\n",
|
||||
"2022-02-23 4.262821\n",
|
||||
"2022-02-22 0.000000\n",
|
||||
"Name: CLOSE, Length: 61, dtype: float64"
|
||||
]
|
||||
},
|
||||
"execution_count": 101,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"macd"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 105,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 115,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"['2022-05-16', '2022-05-15']"
|
||||
]
|
||||
},
|
||||
"execution_count": 115,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": []
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 142,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/html": [
|
||||
"<div>\n",
|
||||
"<style scoped>\n",
|
||||
" .dataframe tbody tr th:only-of-type {\n",
|
||||
" vertical-align: middle;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe tbody tr th {\n",
|
||||
" vertical-align: top;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe thead th {\n",
|
||||
" text-align: right;\n",
|
||||
" }\n",
|
||||
"</style>\n",
|
||||
"<table border=\"1\" class=\"dataframe\">\n",
|
||||
" <thead>\n",
|
||||
" <tr style=\"text-align: right;\">\n",
|
||||
" <th></th>\n",
|
||||
" <th>a</th>\n",
|
||||
" <th>b</th>\n",
|
||||
" <th>c</th>\n",
|
||||
" </tr>\n",
|
||||
" </thead>\n",
|
||||
" <tbody>\n",
|
||||
" <tr>\n",
|
||||
" <th>0</th>\n",
|
||||
" <td>1</td>\n",
|
||||
" <td>2</td>\n",
|
||||
" <td>3</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>1</th>\n",
|
||||
" <td>4</td>\n",
|
||||
" <td>5</td>\n",
|
||||
" <td>6</td>\n",
|
||||
" </tr>\n",
|
||||
" </tbody>\n",
|
||||
"</table>\n",
|
||||
"</div>"
|
||||
],
|
||||
"text/plain": [
|
||||
" a b c\n",
|
||||
"0 1 2 3\n",
|
||||
"1 4 5 6"
|
||||
]
|
||||
},
|
||||
"execution_count": 142,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": []
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 132,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/html": [
|
||||
"<div>\n",
|
||||
"<style scoped>\n",
|
||||
" .dataframe tbody tr th:only-of-type {\n",
|
||||
" vertical-align: middle;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe tbody tr th {\n",
|
||||
" vertical-align: top;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe thead th {\n",
|
||||
" text-align: right;\n",
|
||||
" }\n",
|
||||
"</style>\n",
|
||||
"<table border=\"1\" class=\"dataframe\">\n",
|
||||
" <thead>\n",
|
||||
" <tr style=\"text-align: right;\">\n",
|
||||
" <th></th>\n",
|
||||
" <th>a</th>\n",
|
||||
" <th>b</th>\n",
|
||||
" <th>c</th>\n",
|
||||
" </tr>\n",
|
||||
" </thead>\n",
|
||||
" <tbody>\n",
|
||||
" </tbody>\n",
|
||||
"</table>\n",
|
||||
"</div>"
|
||||
],
|
||||
"text/plain": [
|
||||
"Empty DataFrame\n",
|
||||
"Columns: [a, b, c]\n",
|
||||
"Index: []"
|
||||
]
|
||||
},
|
||||
"execution_count": 132,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"interpreter": {
|
||||
"hash": "4958a03c5ef93b3c628112f436609f44fba8a7f6eb1fb9f266a15f7204ae796a"
|
||||
},
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3.10.2 ('stock': venv)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.10.2"
|
||||
},
|
||||
"orig_nbformat": 4
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 2
|
||||
}
|
Loading…
Reference in New Issue
Block a user